Repository: rcsb/py-rcsbsearchapi (language: python)

Issue:
Change SchemaGroup to act as dict and documentation edits
SchemaGroup
- Removed rcsb_attributes_dict but kept new methods/overloaded other methods to allow SchemaGroup to contain the same information and also be a dictionary
- Added method to return list of all attributes
- Making an AttributeQuery with "rcsb_id" and no search service will throw an error. "attrs.rcsb_id" will also throw an error and say make an AttributeQuery and specify service.

Documentation
- Rewrote README and moved previous README info into readthedocs "Query" page
- Reorganized readthedocs and added a few pages
    - Attribute searching methods
    - Moved Sequence Motif Search, Facets, etc into "Additional Examples" page
Documentation reorg and updates
Also made two tweaks to `search.py` and `schema.py`:

- `search.py` - Reverted to making `type` optional again for `Attr` instantiation, since making it required may break code that users may have which doesn't specify the type explicitly.
- `schema.py` - Make `SchemaGroup.search()` return a list, rather than a `filter` object


Discussion / hints:

Fix patch:
diff --git a/README.md b/README.md
index 16a0453..97607a7 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,7 @@
 [![PyPi Release](https://img.shields.io/pypi/v/rcsbsearchapi.svg)](https://pypi.org/project/rcsbsearchapi/)
 [![Build Status](https://dev.azure.com/rcsb/RCSB%20PDB%20Python%20Projects/_apis/build/status/rcsb.py-rcsbsearchapi?branchName=master)](https://dev.azure.com/rcsb/RCSB%20PDB%20Python%20Projects/_build/latest?definitionId=39&branchName=master)
 [![Documentation Status](https://readthedocs.org/projects/rcsbsearchapi/badge/?version=latest)](https://rcsbsearchapi.readthedocs.io/en/latest/?badge=latest)
-[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
-[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/rcsb/py-rcsbsearchapi/master)
+<a href="https://colab.research.google.com/github/rcsb/py-rcsbsearchapi/blob/master/notebooks/quickstart.ipynb" target="_parent"><img src="https://colab.research.google.com/assets/colab-badge.svg" alt="Open In Colab"/></a>
 
 # rcsbsearchapi
 
@@ -10,6 +9,10 @@ Python interface for the RCSB PDB Search API.
 
 This package requires Python 3.7 or later.
 
+# Quickstart
+
+## Quickstart
+
 ## Installation
 
 Get it from PyPI:
@@ -18,536 +21,108 @@ Get it from PyPI:
 
 Or, download from [GitHub](https://github.com/rcsb/py-rcsbsearchapi)
 
-## Examples
-
-Here is a quick example of how the package is used. Two syntaxes are available for
-constructing queries: an "operator" API using python's comparators, and a "fluent"
-syntax where terms are chained together. Which to use is a matter of preference.
-Below, you will find examples of Full text and attribute search and sequence search.
-
-A runnable jupyter notebook with this example is available in [notebooks/quickstart.ipynb](notebooks/quickstart.ipynb), or can be run online using binder:
-[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/rcsb/py-rcsbsearchapi/master?labpath=notebooks%2Fquickstart.ipynb)
-
-An additional example including a Covid-19 related example is in [notebooks/covid.ipynb](notebooks/covid.ipynb):
-[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/rcsb/py-rcsbsearchapi/master?labpath=notebooks%2Fcovid.ipynb)
-
-### Operator Example
-
-Here is an example from the [RCSB PDB Search
-API](http://search.rcsb.org/#search-example-1) page, using the operator syntax. This
-query finds symmetric dimers having a twofold rotation with the DNA-binding domain of
-a heat-shock transcription factor. Full text and attribute search is used.
-```python
-from rcsbsearchapi.search import TextQuery
-from rcsbsearchapi import rcsb_attributes as attrs
-
-# Create terminals for each query
-q1 = TextQuery('"heat-shock transcription factor"')
-q2 = attrs.rcsb_struct_symmetry.symbol == "C2"
-q3 = attrs.rcsb_struct_symmetry.kind == "Global Symmetry"
-q4 = attrs.rcsb_entry_info.polymer_entity_count_DNA >= 1
-
-# combined using bitwise operators (&, |, ~, etc)
-query = q1 & (q2 & q3 & q4)
-
-# Call the query to execute it
-for assemblyid in query("assembly"):
-    print(assemblyid)
-```
-For a full list of attributes, please refer to the [RCSB PDB
-schema](http://search.rcsb.org/rcsbsearch/v2/metadata/schema).
-
-### Fluent Example
-
-Here is the same example using the
-[fluent](https://en.wikipedia.org/wiki/Fluent_interface) syntax.
-```python
-from rcsbsearchapi.search import TextQuery, AttributeQuery, Attr
-
-# Start with a Attr or TextQuery, then add terms
-results = TextQuery("heat-shock transcription factor").and_(
-    # Add attribute node as fully-formed AttributeQuery
-    AttributeQuery(attribute="rcsb_struct_symmetry.symbol", operator="exact_match", value="C2") \
-    # Add attribute node as Attr with chained operations
-    .and_(Attr("rcsb_struct_symmetry.kind")).exact_match("Global Symmetry") \
-    # Add attribute node by name (converted to Attr) with chained operations
-    .and_("rcsb_entry_info.polymer_entity_count_DNA").greater_or_equal(1)
-    ).exec("assembly")
-
-# Exec produces an iterator of IDs
-for assemblyid in results:
-    print(assemblyid)
-```
-### Structural Attribute Search and Chemical Attribute Search Combination
-
-Grouping of a Structural Attribute query and Chemical Attribute query is permitted as long as grouping is done correctly and search services are specified accordingly. Note the example below. More details on attributes that are available for text searches can be found on the [RCSB PDB Search API](https://search.rcsb.org/#search-attributes) page.
-```python
-from rcsbsearchapi.const import CHEMICAL_ATTRIBUTE_SEARCH_SERVICE, STRUCTURE_ATTRIBUTE_SEARCH_SERVICE
-from rcsbsearchapi.search import AttributeQuery
-
-# By default, service is set to "text" for structural attribute search
-q1 = AttributeQuery("exptl.method", "exact_match", "electron microscopy",
-                    STRUCTURE_ATTRIBUTE_SEARCH_SERVICE # this constant specifies "text" service
-                    )
-
-# Need to specify chemical attribute search service - "text_chem"
-q2 = AttributeQuery("drugbank_info.brand_names", "contains_phrase", "tylenol",
-                    CHEMICAL_ATTRIBUTE_SEARCH_SERVICE # this constant specifies "text_chem" service
-                    )
-
-query = q1 & q2 # combining queries
-
-list(query())
-```
-### Computed Structure Models
-
-The [RCSB PDB Search API](https://search.rcsb.org/#results_content_type) page provides information on how to include Computed Structure Models (CSMs) into a search query. Here is a code example below. This query returns IDs for experimental and computed structure models associated with "hemoglobin". Queries for *only* computed models or *only* experimental models can also be made (default).
-```python
-from rcsbsearchapi.search import TextQuery
-
-q1 = TextQuery("hemoglobin")
-
-# add parameter as a list with either "computational" or "experimental" or both as list values
-q2 = q1(return_content_type=["computational", "experimental"])
-
-list(q2)
-```
-### Return Types and Attribute Search
-
-A search query can return different result types when a return type is specified. 
-Below are examples on specifying return types Polymer Entities,
-Non-polymer Entities, Polymer Instances, and Molecular Definitions, using a Structure Attribute query. 
-More information on return types can be found in the 
-[RCSB PDB Search API](https://search.rcsb.org/#building-search-request) page.
-```python
-from rcsbsearchapi.search import AttributeQuery
-
-q1 = AttributeQuery("rcsb_entry_container_identifiers.entry_id", "in", ["4HHB"]) # query for 4HHB deoxyhemoglobin
-
-# Polymer entities
-for poly in q1("polymer_entity"): # include return type as a string parameter for query object
-    print(poly)
-    
-# Non-polymer entities
-for nonPoly in q1("non_polymer_entity"):
-    print(nonPoly)
-    
-# Polymer instances
-for polyInst in q1("polymer_instance"):
-    print(polyInst)
-    
-# Molecular definitions
-for mol in q1("mol_definition"):
-    print(mol)
-```
-### Counting Results
-
-If only the number of results is desired, the count function can be used. This query returns the number of experimental models associated with "hemoglobin".
-```python
-from rcsbsearchapi.search import TextQuery
-
-q1 = TextQuery("hemoglobin")
-
-# N.B., Just as shown above for `query()`, `return_type` and `return_content_type` can also be specified as parameters to `count()`
-q1.count()
-```
-### Obtaining Scores for Results
-
-Results can be returned alongside additional metadata, including result scores. To return this metadata, set the `results_verbosity` parameter to "verbose" (all metadata), "minimal" (scores only), or "compact" (default, no metadata). If set to "verbose" or "minimal", results will be returned as a list of dictionaries. For example, here we get all experimental models associated with "hemoglobin", along with their scores.
-```python
-from rcsbsearchapi.search import TextQuery
-
-q1 = TextQuery("hemoglobin")
-for idscore in list(q1(results_verbosity="minimal")):
-    print(idscore)
-```
-### Protein Sequence Search Example
-
-Below is an example from the [RCSB PDB Search API](https://search.rcsb.org/#search-example-3) page,
-using the sequence search function.
-This query finds macromolecular PDB entities that share 90% sequence identity with
-GTPase HRas protein from *Gallus gallus* (*Chicken*).
-```python
-from rcsbsearchapi.search import SequenceQuery
-
-# Use SequenceQuery class and add parameters
-results = SequenceQuery("MTEYKLVVVGAGGVGKSALTIQLIQNHFVDEYDPTIEDSYRKQVVIDGET" +
-                        "CLLDILDTAGQEEYSAMRDQYMRTGEGFLCVFAINNTKSFEDIHQYREQI" +
-                        "KRVKDSDDVPMVLVGNKCDLPARTVETRQAQDLARSYGIPYIETSAKTRQ" +
-                        "GVEDAFYTLVREIRQHKLRKLNPPDESGPGCMNCKCVIS", 1, 0.9)
-    
-# results("polymer_entity") produces an iterator of IDs with return type - polymer entities
-for polyid in results("polymer_entity"):
-    print(polyid)
-```
-### Sequence Motif Search Example
-
-Below is an example from the [RCSB PDB Search API](https://search.rcsb.org/#search-example-6) page,
-using the sequence motif search function. 
-This query retrives occurences of the His2/Cys2 Zinc Finger DNA-binding domain as
-represented by its PROSITE signature. 
-```python
-from rcsbsearchapi.search import SeqMotifQuery
-
-# Use SeqMotifQuery class and add parameters
-results = SeqMotifQuery("C-x(2,4)-C-x(3)-[LIVMFYWC]-x(8)-H-x(3,5)-H.",
-                        pattern_type="prosite",
-                        sequence_type="protein")
-
-# results("polymer_entity") produces an iterator of IDs with return type - polymer entities
-for polyid in results("polymer_entity"):
-    print(polyid)
-```
-
-You can also use a regular expression (RegEx) to make a sequence motif search.
-As an example, here is a query for the zinc finger motif that binds Zn in a DNA-binding domain:
-```python
-from rcsbsearchapi.search import SeqMotifQuery
-
-results = SeqMotifQuery("C.{2,4}C.{12}H.{3,5}H", pattern_type="regex", sequence_type="protein")
-
-for polyid in results("polymer_entity"):
-    print(polyid)
-```
-
-You can use a standard amino acid sequence to make a sequence motif search. 
-X can be used to allow any amino acid in that position. 
-As an example, here is a query for SH3 domains:
-```python
-from rcsbsearchapi.search import SeqMotifQuery
-
-# By default, the pattern_type argument is "simple" and the sequence_type argument is "protein".
-results = SeqMotifQuery("XPPXP")  # X is used as a "variable residue" and can be any amino acid. 
+## Getting Started
+Full documentation available at [readthedocs](https://rcsbsearchapi.readthedocs.io/en/latest/index.html)
 
-for polyid in results("polymer_entity"):
-    print(polyid)
-```
-
-All 3 of these pattern types can be used to search for DNA and RNA sequences as well.
-Demonstrated are 2 queries, one DNA and one RNA, using the simple pattern type:
-```python
-from rcsbsearchapi.search import SeqMotifQuery
-
-# DNA query: this is a query for a T-Box.
-dna = SeqMotifQuery("TCACACCT", sequence_type="dna")
+### Basic Query Construction
 
-print("DNA results:")
-for polyid in dna("polymer_entity"):
-    print(polyid)
+#### Full-text search
+To perform a "full-text" search for structures associated with the term "Hemoglobin", you can create a `TextQuery`:
 
-# RNA query: 6C RNA motif
-rna = SeqMotifQuery("CCCCCC", sequence_type="rna")
-print("RNA results:")
-for polyid in rna("polymer_entity"):
-    print(polyid)
-```
-### Structure Similarity Query Example
-
-The PDB archive can be queried using the 3D shape of a protein structure. To perform this query, 3D protein structure data must be provided as an input or parameter, A chain ID or assembly ID must be specified, whether the input structure data should be compared to Assemblies or Polymer Entity Instance (Chains) is required, and defining the search type as either strict or relaxed is required. More information on how Structure Similarity Queries work can be found on the [RCSB PDB Structure Similarity Search](https://www.rcsb.org/docs/search-and-browse/advanced-search/structure-similarity-search) page.
-```python
-from rcsbsearchapi.search import StructSimilarityQuery
-
-# Basic query: querying using entry ID and default values assembly ID "1", operator "strict", and target search space "Assemblies"
-q1 = StructSimilarityQuery(entry_id="4HHB")
-
-# Same example but with parameters explicitly specified
-q1 = StructSimilarityQuery(structure_search_type="entry_id",
-                           entry_id="4HHB",
-                           structure_input_type="assembly_id",
-                           assembly_id="1",
-                           operator="strict_shape_match",
-                           target_search_space="assembly"
-                           )
-for id in q1("assembly"):
-    print(id)
-```
-Below is a more complex example that utilizes chain ID, relaxed search operator, and polymer entity instance or target search space. Specifying whether the input structure
-type is chain id or assembly id is very important. For example, specifying chain ID as the input structure type but inputting an assembly ID can lead to
-an error.
 ```python
-from rcsbsearchapi.search import StructSimilarityQuery
-
-# More complex query with entry ID value "4HHB", chain ID "B", operator "relaxed", and target search space "Chains"
-q2 = StructSimilarityQuery(structure_search_type="entry_id",
-                                   entry_id="4HHB",
-                                   structure_input_type="chain_id",
-                                   chain_id="B",
-                                   operator="relaxed_shape_match",
-                                   target_search_space="polymer_entity_instance")
-list(q2())
-```
-Structure similarity queries also allow users to upload a file from their local computer or input a file url from the website to query the PDB archive for similar proteins. The file represents a target protein structure in the file formats "cif", "bcif", "pdb", "cif.gz", or "pdb.gz". If a user wants to use a file url for queries, the user must specify the structure search type, the value (being the url), and the file format of the file. This is also the same case for file upload, except the value is the absolute path leading to the file that is in the local machine. An example for file url is below for 4HHB (hemoglobin).
-```python
-from rcsbsearchapi.search import StructSimilarityQuery
-
-q3 = StructSimilarityQuery(structure_search_type="file_url",
-                           file_url="https://files.rcsb.org/view/4HHB.cif",
-                           file_format="cif")
-list(q3())
-
-# If you want to upload your own structure file for similarity search, you can do so by using the `file_path` parameter:
-q4 = StructSimilarityQuery(structure_search_type="file_upload",
-                           file_path="/PATH/TO/FILE.cif",  # specify local model file path
-                           file_format="cif")
-list(q4())
-```
-
-### Structure Motif Query Examples
+from rcsbsearchapi import TextQuery
 
-The PDB Archive can also be queried by using a "motif" found in these 3D structures. To perform this type of query, an entry_id or a file URL/path must be provided, along with residues (which are parts of 3D structures.) This is the bare minimum needed to make a search, but there are lots of other parameters that can be added to a Structure Motif Query (see [full search schema](https://search.rcsb.org/redoc/index.html)).
+# Search for structures associated with the phrase "Hemoglobin"
+query = TextQuery(value="Hemoglobin")
 
-To make a Structure Motif Query, you must first define anywhere from 2-10 "residues" that will be used in the query. Each individual residue has a Chain ID, Operator, Residue Number, and Exchanges (optional) that can be declared in that order using positonal arguments, or using the "chain_id", "struct_oper_id", and "label_seq_id" to define what parameter you are passing through. All 3 of the required parameters must be included, or the package will throw an AssertionError. 
-
-Each residue can only have a maximum of 4 Exchanges, and each query can only have 16 exchanges total. Violating any of these rules will cause the package to throw an AssertionError. 
-
-Examples of how to instantiate Residues can be found below. These can then be put into a list and passed through to a Structure Motif Query.
-```python
-from rcsbsearchapi.search import StructureMotifResidue
+# Execute the query by running it as a function
+results = query()
 
-# construct a Residue with a Chain ID of A, an operator of 1, a residue 
-# number of 192, and Exchanges of "LYS" and "HIS"
-Res1 = StructureMotifResidue("A", "1", 192, ["LYS", "HIS"])
-# as for what is a valid "Exchange", the package provides these as a literal,
-# and they should be type checked. 
-
-# you can also specify the arguments:
-# this query is the same as above. 
-Res2 = StructureMotifResidue(struct_oper_id="1", chain_id="A", exchanges=["LYS", "HIS"], label_seq_id=192)
-
-# after delcaring a minimum of 2 and as many as 10 residues, they can be passed into a list for use in the query itself:
-Res3 = StructureMotifResidue("A", "1", 162)  # exchanges are optional
-
-ResList = [Res1, Res3]
+# Results are returned as an iterator of result identifiers.
+for rid in results:
+    print(rid)
 ```
-From there, these Residues can be used in a query. As stated before, you can only include 2 - 10 residues in a query. If you fail to provide residues for a query, or provide the wrong amount, the package will throw a ValueError. 
-
-For a Structure Motif Query using an entry_id, the only other necessary value that must be passed into the query is the residue list. The default type of query is an entry_id query. 
 
-As this type of query has a lot of optional parameters, do *not* use positional arguments as more than likely an error will occur. 
+#### Attribute search
+To perform a search for specific structure or chemical attributes, you can create an `AttributeQuery`.
 
-Below is an example of a basic entry_id Structure Motif Query, with the residues declared earlier:
 ```python
-from rcsbsearchapi.search import StructMotifQuery
+from rcsbsearchapi import AttributeQuery
 
-q1 = StructMotifQuery(entry_id="2MNR", residue_ids=ResList)
-list(q1())
-```
-Like with Structure Similarity Queries, a file url or filepath can also be provided to the program. These can take the place of an entry_id. 
-
-For a file url query, you *must* provide both a valid file URL (a string), and the file's file extension (also as a string). Failure to provide these elements correctly will cause the package to throw an AssertionError. 
+# Construct a query searching for structures from humans
+query = AttributeQuery(
+    attribute="rcsb_entity_source_organism.scientific_name",
+    operator="exact_match",  # Other operators include "contains_phrase", "exists", and more
+    value="Homo sapiens"
+)
 
-Below is an example of the same query as above, only this time providing a file url:
-```python
-link = "https://files.rcsb.org/view/2MNR.cif"
-q2 = StructMotifQuery(structure_search_type="file_url", url=link, file_extension="cif", residue_ids=ResList)
-# structure_search_type MUST be provided. A mismatched query type will cause an error. 
-list(q2())
+# Execute query and construct a list from results
+results = list(query())
+print(results)
 ```
-Like with Structure Similarity Queries, a filepath to a file may also be provided. This file must be a valid file accepted by the search API. A file extension must also be provided with the file upload. 
 
-The query would look something like this:
-```python
-filepath = "/absolute/path/to/file.cif"
-q3 = StructMotifQuery(structure_search_type="file_upload", file_path=filepath, file_extension="cif", residue_ids=ResList)
+Refer to the [Search Attributes](https://search.rcsb.org/structure-search-attributes.html) and [Chemical Attributes](https://search.rcsb.org/chemical-search-attributes.html) documentation for a full list of attributes and applicable operators.
 
-list(q3())
-```
-There are many additional parameters that Structure Motif Query supports. These include a variety of features such as backbone distance tolerance, side chain distance tolerance, angle tolerance, RMSD cutoff, limits (stop searching after this many hits), atom pairing schemes, motif pruning strategy, allowed structures, and excluded structures. These can be mixed and matched as needed to make accurate and useful queries. All of these have some default value which is used when a parameter isn't provided. These parameters conform to the defaults used by the Search API. 
+Alternatively, you can also construct attribute queries with comparative operators using the `rcsb_attributes` object (which also allows for names to be tab-completed):
 
-Below will demonstrate how to define these parameters using non-positional arguments:
-```python
-# specifying backbone distance tolerance: 0-3, default is 1
-# allowed backbone distance tolerance in Angstrom. 
-backbone = StructMotifQuery(entry_id="2MNR", backbone_distance_tolerance=2, residue_ids=ResList)
-list(backbone())
-
-# specifying sidechain distance tolerance: 0-3, default is 1
-# allowed side-chain distance tolerance in Angstrom.
-sidechain = StructMotifQuery(entry_id="2MNR", side_chain_distance_tolerance=2, residue_ids=ResList)
-list(sidechain())
-
-# specifying angle tolerance: 0-3, default is 1
-# allowed angle tolerance in multiples of 20 degrees. 
-angle = StructMotifQuery(entry_id="2MNR", angle_tolerance=2, residue_ids=ResList)
-list(angle())
-
-# specifying RMSD cutoff: >=0, default is 2
-# Threshold above which hits will be filtered by RMSD
-rmsd = StructMotifQuery(entry_id="2MNR", rmsd_cutoff=1, residue_ids=ResList)
-list(rmsd())
-
-# specifying limit: >=0, default excluded
-# Stop accepting results after this many hits. 
-limit = StructMotifQuery(entry_id="2MNR", limit=100, residue_ids=ResList)
-list(limit())
-
-# specifying atom pairing scheme, default = "SIDE_CHAIN"
-# ENUM: "ALL", "BACKBONE", "SIDE_CHAIN", "PSUEDO_ATOMS"
-# this is typechecked by a literal. 
-# Which atoms to consider to compute RMSD scores and transformations. 
-atom = StructMotifQuery(entry_id="2MNR", atom_pairing_scheme="ALL", residue_ids=ResList)
-list(atom())
-
-# specifying motif pruning strategy, default = "KRUSKAL"
-# ENUM: "NONE", "KRUSKAL"
-# this is typechecked by a literal in the package. 
-# Specifies how many query motifs are "pruned". KRUSKAL leads to less stringent queries, and faster results.
-pruning = StructMotifQuery(entry_id="2MNR", motif_pruning_strategy="NONE", residue_ids=ResList)
-list(pruning())
-
-# specifying allowed structures, default excluded
-# specify the structures you wish to allow in the return result. As an example,
-# we could only allow the results from the limited query we ran earlier. 
-allowed = StructMotifQuery(entry_id="2MNR", allowed_structures=list(limit()), residue_ids=ResList)
-list(allowed())
-
-# specifying structures to exclude, default excluded
-# specify structures to exclude from a query. We could, for example,
-# exclude the results of the previous allowed query. 
-excluded = StructMotifQuery(entry_id="2MNR", excluded_structures=list(allowed()), residue_ids=ResList)
-list(excluded())
-```
-The Structure Motif Query can be used to make some very specific queries. Below is an example of a query that retrives occurances of the enolase superfamily, a group of proteins diverse in sequence and structure that are all capable of abstracting a proton from a carboxylic acid. Position-specific exchanges are crucial to represent this superfamily accurately.
 ```python
-Res1 = StructureMotifResidue("A", "1", 162, ["LYS", "HIS"])
-Res2 = StructureMotifResidue("A", "1", 193)
-Res3 = StructureMotifResidue("A", "1", 219)
-Res4 = StructureMotifResidue("A", "1", 245, ["GLU", "ASP", "ASN"])
-Res5 = StructureMotifResidue("A", "1", 295, ["HIS", "LYS"])
-
-ResList = [Res1, Res2, Res3, Res4, Res5]
+from rcsbsearchapi import rcsb_attributes as attrs
 
-query = StructMotifQuery(entry_id="2MNR", residue_ids=ResList)
+# Search for structures from humans
+query = attrs.rcsb_entity_source_organism.scientific_name == "Homo sapiens"
 
-list(query())
+# Run query and construct a list from results
+results = list(query())
+print(results)
 ```
-### Chemical Similarity Query
-
-When you have unique chemical information (e.g., a chemical formula or descriptor) you can use this information to find chemical components (e.g., drugs, inhibitors, modified residues, or building blocks such as amino acids, nucleotides, or sugars), so that it is similar to the formula or descriptor used in the query (perhaps one or two atoms/groups are different), is part of a larger molecule (i.e., the specified formula/descriptor is a substructure), or is exactly or very closely matches the formula or descriptor used in the query. 
 
-The search can also be used to identify PDB structures that include the chemical component(s) which match or are similar to the query. These structures can then be examined to learn about the interactions of the component within the structure. More information on Chemical Similarity Queries can be found on the [RCSB PDB Chemical Similarity Search](https://www.rcsb.org/docs/search-and-browse/advanced-search/chemical-similarity-search) page.
+#### Grouping sub-queries
 
-To do a Chemical Similarity query, you must first specify one of two possible query options which are formula and descriptors. Formula allows queries to be made by providing a chemical formula. Descriptors allow you to search by chemical notations for example. Each Query option has its own distinct set of parameters, but both options require a value.
+You can combine multiple queries using Python bitwise operators. 
 
-The formula query option comes with a match subset parameter which allows users to search chemical components whose formula exactly match the query or matches any portion of the query. The descriptor query option comes with a descriptor type parameter and match type parameter. The descriptor type parameter specifies what type of descriptor the input value is. There are two options which are SMILES (Simplified Molecular Input Line Entry Specification) and InChI (International Chemical Identifier). The match type parameter has six options which are Similar Ligands (Quick Screen), Similar Ligands (Stereospecific), Similar Ligands (including Stereoisomers), Substructure (Stereospecific), Substructure (including Stereoisomers), and Exact match.
-
-When doing Chemical Similarity Queries in this tool, it is important to note that by default the query option is set to formula and match subset is set to False. An example of how that looks like is below.
 ```python
-from rcsbsearchapi.search import ChemSimilarityQuery
+from rcsbsearchapi import rcsb_attributes as attrs
 
-# Basic query with default values: query type = formula and match subset = False
-q1 = ChemSimilarityQuery(value="C12 H17 N4 O S")
+# Query for human epidermal growth factor receptor (EGFR) structures (UniProt ID P00533)
+#  with investigational or experimental drugs bound
+q1 = attrs.rcsb_polymer_entity_container_identifiers.reference_sequence_identifiers.database_accession == "P00533"
+q2 = attrs.rcsb_entity_source_organism.scientific_name == "Homo sapiens"
+q3 = attrs.drugbank_info.drug_groups == "investigational"
+q4 = attrs.drugbank_info.drug_groups == "experimental"
 
-# Same example but with all the parameters listed
-q1 = ChemSimilarityQuery(value="C12 H17 N4 O S",
-                         query_type="formula",
-                         match_subset=False)
-list(q1())
-```
-Below is are two examples of using query option descriptor. Both descriptor type parameters are also used.
-```python
-from rcsbsearchapi.search import ChemSimilarityQuery
-
-# Query with type = descriptor, descriptor type = SMILES, match type = similar ligands (sterospecific) or graph-relaxed-stereo
-q2 = ChemSimilarityQuery(value="Cc1c(sc[n+]1Cc2cnc(nc2N)C)CCO",
-                         query_type="descriptor",
-                         descriptor_type="SMILES",
-                         match_type="graph-relaxed-stereo")
-list(q2())
-```
-```python
-from rcsbsearchapi.search import ChemSimilarityQuery
-
-# Query with type = descriptor, descriptor type = InChI, match type = substructure (sterospecific) or sub-struct-graph-relaxed-stereo
-q3 = ChemSimilarityQuery(value="InChI=1S/C13H10N2O4/c16-10-6-5-9(11(17)14-10)15-12(18)7-3-1-2-4-8(7)13(15)19/h1-4,9H,5-6H2,(H,14,16,17)/t9-/m0/s1",
-                         query_type="descriptor",
-                         descriptor_type="InChI",
-                         match_type="sub-struct-graph-relaxed-stereo")
-list(q3())
-```
+# Structures matching UniProt ID P00533 AND from humans
+#  AND (investigational OR experimental drug group)
+query = q1 & q2 & (q3 | q4)
 
-### Faceted Query Examples
-In order to group and perform calculations and statistics on PDB data by using a simple search query, you can use a faceted query (or facets). Facets arrange search results into categories (buckets) based on the requested field values. More information on Faceted Queries can be found [here](https://search.rcsb.org/#using-facets). All facets should be provided with `name`, `aggregation_type`, and `attribute` values. Depending on the aggregation type, other parameters must also be specified. The `facets()` function runs the query `q` using the specified facet(s), and returns a list of dictionaries:
-```python
-from rcsbsearchapi.search import AttributeQuery, Facet, Range
-
-q = AttributeQuery("rcsb_accession_info.initial_release_date", operator="greater", value="2019-08-20")
-q.facets(facets=Facet(name="Methods", aggregation_type="terms", attribute="exptl.method"))
+# Execute query and print first 10 ids
+results = list(query())
+print(results[:10])
 ```
 
-#### Term Facets
-Terms faceting is a multi-bucket aggregation where buckets are dynamically built - one per unique value. We can specify the minimum count (`>= 0`) for a bucket to be returned using the parameter `min_interval_population` (default value `1`). We can also control the number of buckets returned (`<= 65336`) using the parameter `max_num_intervals` (default value `65336`).
-```python
-# This is the default query, used by the RCSB Search API when no query is explicitly specified.
-# This default query will be used for most of the examples found below for faceted queries.
-base_q = AttributeQuery("rcsb_entry_info.structure_determination_methodology", operator="exact_match", value="experimental") 
+These examples are in `operator` syntax. You can also make queries in `fluent` syntax. Learn more about both syntaxes and implementation details in [Constructing and Executing Queries](query_construction.md#constructing-and-executing-queries).
 
-base_q.facets(facets=Facet(name="Journals", aggregation_type="terms", attribute="rcsb_primary_citation.rcsb_journal_abbrev", min_interval_population=1000))
-```
+### Supported Search Services
+The list of supported search service types are listed in the table below. For more details on their usage, see [Search Service Types](query_construction.md#search-service-types).
 
-#### Histogram Facets
-Histogram facets build fixed-sized buckets (intervals) over numeric values. The size of the intervals must be specified in the parameter `interval`. We can also specify `min_interval_population` if desired.
-```python
-base_q.facets(return_type="polymer_entity", facets=Facet(name="Formula Weight", aggregation_type="histogram", attribute="rcsb_polymer_entity.formula_weight", interval=50, min_interval_population=1))
-```
+|Search service                    |QueryType                 |
+|----------------------------------|--------------------------|
+|Full-text                         |`TextQuery()`             |
+|Attribute (structure or chemical) |`AttributeQuery()`        |
+|Sequence similarity               |`SequenceQuery()`         |
+|Sequence motif                    |`SequenceMotifQuery()`    |
+|Structure similarity              |`StructSimilarityQuery()` |
+|Structure motif                   |`StructMotifQuery()`      |
+|Chemical similarity               |`ChemSimilarityQuery()`   |
 
-#### Date Histogram Facets
-Similar to histogram facets, date histogram facetes build buckets over date values. For date histogram aggregations, we must specify `interval="year"`. Again, we may also specify `min_interval_population`.
-```python
-base_q.facets(facets=Facet(name="Release Date", aggregation_type="date_histogram", attribute="rcsb_accession_info.initial_release_date", interval="year", min_interval_population=1))
-```
+Learn more about available search services on the [RCSB PDB Search API docs](https://search.rcsb.org/#search-services).
 
-#### Range Facets
-We can define the buckets ourselves by using range facets. In order to specify the ranges, we use the `Range` class. Note that the range includes the `start` value and excludes the `end` value (`include_lower` and `include_upper` should not be specified). If the `start` or `end` is omitted, the minimum or maximum boundaries will be used by default. The buckets should be provided as a list of `Range` objects to the `ranges` parameter.  
-```python
-base_q.facets(facets=Facet(name="Resolution Combined", aggregation_type="range", attribute="rcsb_entry_info.resolution_combined", ranges=[Range(start=None,end=2), Range(start=2, end=2.2), Range(start=2.2, end=2.4), Range(start=4.6, end=None)]))
-```
-
-#### Date Range Facets
-Date range facets allow us to specify date values as bucket ranges, using [date math expressions](https://search.rcsb.org/#date-math-expressions).
-```python
-base_q.facets(facets=Facet(name="Release Date", aggregation_type="date_range", attribute="rcsb_accession_info.initial_release_date", ranges=[Range(start=None,end="2020-06-01||-12M"), Range(start="2020-06-01", end="2020-06-01||+12M"), Range(start="2020-06-01||+12M", end=None)]))
-```
-
-#### Cardinality Facets 
-Cardinality facets return a single value: the count of distinct values returned for a given field. A `precision_threshold` (`<= 40000`, default value `40000`) may be specified.
-```python
-base_q.facets(facets=Facet(name="Organism Names Count", aggregation_type="cardinality", attribute="rcsb_entity_source_organism.ncbi_scientific_name"))
-```
+## Jupyter Notebooks
+A runnable jupyter notebook is available in [notebooks/quickstart.ipynb](https://github.com/rcsb/py-rcsbsearchapi/blob/master/notebooks/quickstart.ipynb), or can be run online using Google Colab:
+<a href="https://colab.research.google.com/github/rcsb/py-rcsbsearchapi/blob/master/notebooks/quickstart.ipynb" target="_parent"><img src="https://colab.research.google.com/assets/colab-badge.svg" alt="Open In Colab"/></a>
 
-#### Multidimensional Facets
-Complex, multi-dimensional aggregations are possible by specifying additional facets in the `nested_facets` parameter, as in the example below:
-```python
-f1 = Facet(name="Polymer Entity Types", aggregation_type="terms", attribute="rcsb_entry_info.selected_polymer_entity_types")
-f2 = Facet(name="Release Date", aggregation_type="date_histogram", attribute="rcsb_accession_info.initial_release_date", interval="year")
-base_q.facets(facets=Facet(name="Experimental Method", aggregation_type="terms", attribute="rcsb_entry_info.experimental_method", nested_facets=[f1, f2]))
-```
+An additional Covid-19 related example is in [notebooks/covid.ipynb](https://github.com/rcsb/py-rcsbsearchapi/blob/master/notebooks/covid.ipynb):
+<a href="https://colab.research.google.com/github//rcsb/py-rcsbsearchapi/blob/master/notebooks/covid.ipynb" target="_parent"><img src="https://colab.research.google.com/assets/colab-badge.svg" alt="Open In Colab"/></a>
 
-#### Filter Facets
-Filters allow us to filter documents that contribute to bucket count. Similar to queries, we can group several `TerminalFilter`s into a single `GroupFilter`. We can combine a filter with a facet using the `FilterFacet` class. Terminal filters should specify an `attribute` and `operator`, as well as possible a `value` and whether or not it should be a `negation` and/or `case_sensitive`. Group filters should specify a `logical_operator` (which should be either `"and"` or `"or"`) and a list of filters (`nodes`) that should be combined. Finally, the `FilterFacet` should be provided with a filter and a (list of) facet(s). Here are some examples:
-```python
-from rcsbsearchapi.search import TerminalFilter, GroupFilter, FilterFacet
-tf1 = TerminalFilter(attribute="rcsb_polymer_instance_annotation.type", operator="exact_match", value="CATH")
-tf2 = TerminalFilter(attribute="rcsb_polymer_instance_annotation.annotation_lineage.id", operator="in", value=["2.140.10.30", "2.120.10.80"])
-ff2 = FilterFacet(filters=tf2, facets=Facet("CATH Domains", "terms", "rcsb_polymer_instance_annotation.annotation_lineage.id", min_interval_population=1))
-ff1 = FilterFacet(filters=tf1, facets=ff2)
-base_q.facets("polymer_instance", ff1)
-
-tf1 = TerminalFilter(attribute="rcsb_struct_symmetry.kind", operator="exact_match", value="Global Symmetry", negation=False)
-f2 = Facet(name="ec_terms", aggregation_type="terms", attribute="rcsb_polymer_entity.rcsb_ec_lineage.id")
-f1 = Facet(name="sym_symbol_terms", aggregation_type="terms", attribute="rcsb_struct_symmetry.symbol", nested_facets=f2)
-ff = FilterFacet(filters=tf1, facets=f1)
-q1 = AttributeQuery("rcsb_assembly_info.polymer_entity_count", operator="equals", value=1)
-q2 = AttributeQuery("rcsb_assembly_info.polymer_entity_instance_count", operator="greater", value=1)
-q = q1 & q2
-q.facets("assembly", ff)
-
-tf1 = TerminalFilter(attribute="rcsb_polymer_entity_group_membership.aggregation_method", operator="exact_match", value="sequence_identity")
-tf2 = TerminalFilter(attribute="rcsb_polymer_entity_group_membership.similarity_cutoff", operator="equals", value=100)
-gf = GroupFilter(logical_operator="and", nodes=[tf1, tf2])
-ff = FilterFacet(filters=gf, facets=Facet("Distinct Protein Sequence Count", "cardinality", "rcsb_polymer_entity_group_membership.group_id"))
-base_q.facets("polymer_entity", ff)
-```
 
 ## Supported Features
 
@@ -567,10 +142,6 @@ The following table lists the status of current and planned features.
 
 Contributions are welcome for unchecked items!
 
-## Documentation
-
-Detailed documentation is at [rcsbsearchapi.readthedocs.io](https://rcsbsearchapi.readthedocs.io/en/latest/)
-
 ## License
 
 Code is licensed under the BSD 3-clause license. See [LICENSE](LICENSE) for details.
diff --git a/codemeta.json b/codemeta.json
new file mode 100644
index 0000000..929697e
--- /dev/null
+++ b/codemeta.json
@@ -0,0 +1,20 @@
+{
+    "@context": "https://w3id.org/codemeta/3.0",
+    "type": "SoftwareSourceCode",
+    "codeRepository": "https://github.com/rcsb/py-rcsbsearchapi",
+    "dateCreated": "2023-06-08",
+    "datePublished": "2023-06-08",
+    "description": "Python interface for RCSB PDB Search API service at RCSB.org.",
+    "license": "https://spdx.org/licenses/BSD-3-Clause",
+    "name": "rcsbsearchapi",
+    "operatingSystem": [
+        "Linux",
+        "Windows",
+        "macOS"
+    ],
+    "programmingLanguage": "Python 3",
+    "relatedLink": [
+        "https://pypi.org/project/rcsbsearchapi/",
+        "https://rcsbsearchapi.readthedocs.io/en/latest/"
+    ]
+}
\ No newline at end of file
diff --git a/docs/queries.md b/docs/additional_examples.md
similarity index 57%
rename from docs/queries.md
rename to docs/additional_examples.md
index 7162775..f141812 100644
--- a/docs/queries.md
+++ b/docs/additional_examples.md
@@ -1,156 +1,6 @@
-# Queries
+# Additional Examples
 
-Two syntaxes are available for constructing queries: an "operator" API using python's
-comparators, and a "fluent" API where terms are chained together. Which to use is a
-matter of preference, and both construct the same query object.
-
-## Operator syntax
-
-Searches are built up from a series of `Terminal` nodes, which compare structural
-attributes to some search value. In the operator syntax, python's comparator
-operators are used to construct the comparison. The operators are overloaded to
-return `Terminal` objects for the comparisons.
-```python
-from rcsbsearchapi.search import TextQuery
-from rcsbsearchapi import rcsb_attributes as attrs
-
-# Create terminals for each query
-q1 = TextQuery("heat-shock transcription factor")
-q2 = attrs.rcsb_struct_symmetry.symbol == "C2"
-q3 = attrs.rcsb_struct_symmetry.kind == "Global Symmetry"
-q4 = attrs.rcsb_entry_info.polymer_entity_count_DNA >= 1
-```
-Attributes are available from the rcsb_attributes object and can be tab-completed.
-They can additionally be constructed from strings using the `Attr(attribute)`
-constructor. For a full list of attributes, please refer to the [RCSB PDB schema](http://search.rcsb.org/rcsbsearch/v2/metadata/schema).
-
-`Terminal`s are combined into `Group`s using python's bitwise operators. This is
-analogous to how bitwise operators act on python `set` objects. The operators are
-lazy and won't perform the search until the query is executed.
-```python
-query = q1 & (q2 & q3 & q4)  # AND of all queries
-```
-AND (`&`), OR (`|`), and terminal negation (`~`) are implemented directly by the API,
-but the python package also implements set difference (`-`), symmetric difference (`^`),
-and general negation by transforming the query.
-
-Queries are executed by calling them as functions. They return an iterator of result
-identifiers.
-```python
-results = set(query())
-```
-By default, the query will return "entry" results (PDB IDs). It is also possible to
-query other types of results (see [return-types](http://search.rcsb.org/#return-type)
-for options):
-```python
-assemblies = set(query("assembly"))
-```
-### Fluent syntax
-
-The operator syntax is great for simple queries, but requires parentheses or
-temporary variables for complex nested queries. In these cases the fluent syntax may
-be clearer. Queries are built up by appending operations sequentially.
-```python
-from rcsbsearchapi.search import TextQuery, AttributeQuery, Attr
-
-# Start with a Attr or TextQuery, then add terms
-results = TextQuery("heat-shock transcription factor").and_(
-    # Add attribute node as fully-formed AttributeQuery
-    AttributeQuery(attribute="rcsb_struct_symmetry.symbol", operator="exact_match", value="C2") \
-    # Add attribute node as Attr with chained operations
-    .and_(Attr("rcsb_struct_symmetry.kind")).exact_match("Global Symmetry") \
-    # Add attribute node by name (converted to Attr) with chained operations
-    .and_("rcsb_entry_info.polymer_entity_count_DNA").greater_or_equal(1)
-    ).exec("assembly")
-
-# Exec produces an iterator of IDs
-for assemblyid in results:
-    print(assemblyid)
-```
-### Structural Attribute Search and Chemical Attribute Search Combination
-
-Grouping of a Structural Attribute query and Chemical Attribute query is permitted as long as grouping is done correctly and search services are specified accordingly. More details on attributes that are available for text searches can be found on the [RCSB PDB Search API](https://search.rcsb.org/#search-attributes) page.
-```python
-from rcsbsearchapi.const import CHEMICAL_ATTRIBUTE_SEARCH_SERVICE, STRUCTURE_ATTRIBUTE_SEARCH_SERVICE
-from rcsbsearchapi.search import AttributeQuery
-
-# By default, service is set to "text" for structural attribute search
-q1 = AttributeQuery("exptl.method", "exact_match", "electron microscopy",
-                    STRUCTURE_ATTRIBUTE_SEARCH_SERVICE # this constant specifies "text" service
-                    )
-
-# Need to specify chemical attribute search service - "text_chem"
-q2 = AttributeQuery("drugbank_info.brand_names", "contains_phrase", "tylenol",
-                    CHEMICAL_ATTRIBUTE_SEARCH_SERVICE # this constant specifies "text_chem" service
-                    )
-
-query = q1 & q2 # combining queries
-
-list(query())
-```
-### Computed Structure Models
-
-The [RCSB PDB Search API](https://search.rcsb.org/#results_content_type) page provides information on how to include Computed Structure Models (CSMs) into a search query. Here is a code example below. This query returns IDs for experimental and computed structure models associated with "hemoglobin". Queries for *only* computed models or *only* experimental models can also be made (default).
-```python
-from rcsbsearchapi.search import TextQuery
-
-q1 = TextQuery("hemoglobin")
-
-# add parameter as a list with either "computational" or "experimental" or both as list values
-q2 = q1(return_content_type=["computational", "experimental"])
-
-list(q2)
-```
-### Return Types and Attribute Search
-
-A search query can return different result types when a return type is specified. 
-Below are examples on specifying return types Polymer Entities,
-Non-polymer Entities, Polymer Instances, and Molecular Definitions, using a Structure Attribute query. 
-More information on return types can be found in the 
-[RCSB PDB Search API](https://search.rcsb.org/#building-search-request) page.
-```python
-from rcsbsearchapi.search import AttributeQuery
-
-q1 = AttributeQuery("rcsb_entry_container_identifiers.entry_id", "in", ["4HHB"]) # query for 4HHB deoxyhemoglobin
-
-# Polymer entities
-for poly in q1("polymer_entity"): # include return type as a string parameter for query object
-    print(poly)
-    
-# Non-polymer entities
-for nonPoly in q1("non_polymer_entity"):
-    print(nonPoly)
-    
-# Polymer instances
-for polyInst in q1("polymer_instance"):
-    print(polyInst)
-    
-# Molecular definitions
-for mol in q1("mol_definition"):
-    print(mol)
-```
-### Counting Results
-
-If only the number of results is desired, the count function can be used. This query returns the number of experimental models associated with "hemoglobin".
-```python
-from rcsbsearchapi.search import TextQuery
-
-q1 = TextQuery("hemoglobin")
-
-# N.B., Just as shown above for `query()`, `return_type` and `return_content_type` can also be specified as parameters to `count()`
-q1.count()
-```
-### Obtaining Scores for Results
-
-Results can be returned alongside additional metadata, including result scores. To return this metadata, set the `results_verbosity` parameter to "verbose" (all metadata), "minimal" (scores only), or "compact" (default, no metadata). If set to "verbose" or "minimal", results will be returned as a list of dictionaries. For example, here we get all experimental models associated with "hemoglobin", along with their scores.
-```python
-from rcsbsearchapi.search import TextQuery
-
-q1 = TextQuery("hemoglobin")
-for idscore in list(q1(results_verbosity="minimal")):
-    print(idscore)
-```
-### Protein Sequence Search Example
+## Sequence Search Examples
 
 Below is an example from the [RCSB PDB Search API](https://search.rcsb.org/#search-example-3) page, 
 using the sequence search function.
@@ -169,7 +19,7 @@ results = SequenceQuery("MTEYKLVVVGAGGVGKSALTIQLIQNHFVDEYDPTIEDSYRKQVVIDGET" +
 for polyid in results("polymer_entity"):
     print(polyid)
 ```
-### Sequence Motif Search Example
+## Sequence Motif Search Examples
 
 Below is an example from the [RCSB PDB Search API](https://search.rcsb.org/#search-example-6) page,
 using the sequence motif search function. 
@@ -193,7 +43,10 @@ As an example, here is a query for the zinc finger motif that binds Zn in a DNA-
 ```python
 from rcsbsearchapi.search import SeqMotifQuery
 
-results = SeqMotifQuery("C.{2,4}C.{12}H.{3,5}H", pattern_type="regex", sequence_type="protein")
+results = SeqMotifQuery(
+    "C.{2,4}C.{12}H.{3,5}H",
+    pattern_type="regex",
+    sequence_type="protein")
 
 for polyid in results("polymer_entity"):
     print(polyid)
@@ -205,8 +58,9 @@ As an example, here is a query for SH3 domains:
 ```python
 from rcsbsearchapi.search import SeqMotifQuery
 
-# By default, the pattern_type argument is "simple" and the sequence_type argument is "protein".
-results = SeqMotifQuery("XPPXP")  # X is used as a "variable residue" and can be any amino acid. 
+# The default pattern_type argument is "simple" and the sequence_type argument is "protein".
+# X is used as a "variable residue" and can be any amino acid. 
+results = SeqMotifQuery("XPPXP")
 
 for polyid in results("polymer_entity"):
     print(polyid)
@@ -230,13 +84,15 @@ print("RNA results:")
 for polyid in rna("polymer_entity"):
     print(polyid)
 ```
-### Structure Similarity Query Example
+## Structure Similarity Search Examples
 
 The PDB archive can be queried using the 3D shape of a protein structure. To perform this query, 3D protein structure data must be provided as an input or parameter, A chain ID or assembly ID must be specified, whether the input structure data should be compared to Assemblies or Polymer Entity Instance (Chains) is required, and defining the search type as either strict or relaxed is required. More information on how Structure Similarity Queries work can be found on the [RCSB PDB Structure Similarity Search](https://www.rcsb.org/docs/search-and-browse/advanced-search/structure-similarity-search) page.
 ```python
 from rcsbsearchapi.search import StructSimilarityQuery
 
-# Basic query: querying using entry ID and default values assembly ID "1", operator "strict", and target search space "Assemblies"
+# Basic query:
+# Querying using entry ID and default values assembly ID "1",
+# operator "strict", target search space "Assemblies"
 q1 = StructSimilarityQuery(entry_id="4HHB")
 
 # Same example but with parameters explicitly specified
@@ -247,8 +103,8 @@ q1 = StructSimilarityQuery(structure_search_type="entry_id",
                            operator="strict_shape_match",
                            target_search_space="assembly"
                            )
-for id in q1("assembly"):
-    print(id)
+for rid in q1("assembly"):
+    print(rid)
 ```
 Below is a more complex example that utilizes chain ID, relaxed search operator, and polymer entity instance or target search space. Specifying whether the input structure
 type is chain id or assembly id is very important. For example, specifying chain ID as the input structure type but inputting an assembly ID can lead to
@@ -256,7 +112,8 @@ an error.
 ```python
 from rcsbsearchapi.search import StructSimilarityQuery
 
-# More complex query with entry ID value "4HHB", chain ID "B", operator "relaxed", and target search space "Chains"
+# More complex query:
+# Entry ID value "4HHB", chain ID "B", operator "relaxed", and target search space "Chains"
 q2 = StructSimilarityQuery(structure_search_type="entry_id",
                                    entry_id="4HHB",
                                    structure_input_type="chain_id",
@@ -274,14 +131,14 @@ q3 = StructSimilarityQuery(structure_search_type="file_url",
                            file_format="cif")
 list(q3())
 
-# If you want to upload your own structure file for similarity search, you can do so by using the `file_path` parameter:
+# To upload your own structure file, use the `file_path` parameter:
 q4 = StructSimilarityQuery(structure_search_type="file_upload",
                            file_path="/PATH/TO/FILE.cif",  # specify local model file path
                            file_format="cif")
 list(q4())
 ```
 
-### Structure Motif Query Examples
+## Structure Motif Search Examples
 
 The PDB Archive can also be queried by using a "motif" found in these 3D structures. To perform this type of query, an entry_id or a file URL/path must be provided, along with residues (which are parts of 3D structures.) This is the bare minimum needed to make a search, but there are lots of other parameters that can be added to a Structure Motif Query (see [full search schema](https://search.rcsb.org/redoc/index.html)).
 
@@ -293,17 +150,22 @@ Examples of how to instantiate Residues can be found below. These can then be pu
 ```python
 from rcsbsearchapi.search import StructureMotifResidue
 
-# construct a Residue with a Chain ID of A, an operator of 1, a residue 
+# Construct a Residue with a Chain ID of A, an operator of 1, a residue 
 # number of 192, and Exchanges of "LYS" and "HIS"
 Res1 = StructureMotifResidue("A", "1", 192, ["LYS", "HIS"])
-# as for what is a valid "Exchange", the package provides these as a literal,
+# As for what is a valid "Exchange", the package provides these as a literal,
 # and they should be type checked. 
 
-# you can also specify the arguments:
-# this query is the same as above. 
-Res2 = StructureMotifResidue(struct_oper_id="1", chain_id="A", exchanges=["LYS", "HIS"], label_seq_id=192)
+# You can also specify the arguments:
+# This query is the same as above. 
+Res2 = StructureMotifResidue(
+    struct_oper_id="1",
+    chain_id="A",
+    exchanges=["LYS", "HIS"],
+    label_seq_id=192)
 
-# after delcaring a minimum of 2 and as many as 10 residues, they can be passed into a list for use in the query itself:
+# After declaring a minimum of 2 and as many as 10 residues,
+# they can be passed into a list for use in the query itself:
 Res3 = StructureMotifResidue("A", "1", 162)  # exchanges are optional
 
 ResList = [Res1, Res3]
@@ -328,7 +190,11 @@ For a file url query, you *must* provide both a valid file URL (a string), and t
 Below is an example of the same query as above, only this time providing a file url:
 ```python
 link = "https://files.rcsb.org/view/2MNR.cif"
-q2 = StructMotifQuery(structure_search_type="file_url", url=link, file_extension="cif", residue_ids=ResList)
+q2 = StructMotifQuery(
+    structure_search_type="file_url",
+    url=link, file_extension="cif",
+    residue_ids=ResList
+)
 # structure_search_type MUST be provided. A mismatched query type will cause an error. 
 list(q2())
 ```
@@ -337,7 +203,11 @@ Like with Structure Similarity Queries, a filepath to a file may also be provide
 The query would look something like this:
 ```python
 filepath = "/absolute/path/to/file.cif"
-q3 = StructMotifQuery(structure_search_type="file_upload", file_path=filepath, file_extension="cif", residue_ids=ResList)
+q3 = StructMotifQuery(
+    structure_search_type="file_upload",
+    file_path=filepath,
+    file_extension="cif",
+    residue_ids=ResList)
 
 list(q3())
 ```
@@ -347,53 +217,86 @@ Below will demonstrate how to define these parameters using non-positional argum
 ```python
 # specifying backbone distance tolerance: 0-3, default is 1
 # allowed backbone distance tolerance in Angstrom. 
-backbone = StructMotifQuery(entry_id="2MNR", backbone_distance_tolerance=2, residue_ids=ResList)
+backbone = StructMotifQuery(
+    entry_id="2MNR",
+    backbone_distance_tolerance=2,
+    residue_ids=ResList)
 list(backbone())
 
 # specifying sidechain distance tolerance: 0-3, default is 1
 # allowed side-chain distance tolerance in Angstrom.
-sidechain = StructMotifQuery(entry_id="2MNR", side_chain_distance_tolerance=2, residue_ids=ResList)
+sidechain = StructMotifQuery(
+    entry_id="2MNR",
+    side_chain_distance_tolerance=2,
+    residue_ids=ResList
+)
 list(sidechain())
 
 # specifying angle tolerance: 0-3, default is 1
 # allowed angle tolerance in multiples of 20 degrees. 
-angle = StructMotifQuery(entry_id="2MNR", angle_tolerance=2, residue_ids=ResList)
+angle = StructMotifQuery(
+    entry_id="2MNR",
+    angle_tolerance=2,
+    residue_ids=ResList
+)
 list(angle())
 
 # specifying RMSD cutoff: >=0, default is 2
 # Threshold above which hits will be filtered by RMSD
-rmsd = StructMotifQuery(entry_id="2MNR", rmsd_cutoff=1, residue_ids=ResList)
+rmsd = StructMotifQuery(
+    entry_id="2MNR",
+    rmsd_cutoff=1,
+    residue_ids=ResList
+)
 list(rmsd())
 
 # specifying limit: >=0, default excluded
 # Stop accepting results after this many hits. 
-limit = StructMotifQuery(entry_id="2MNR", limit=100, residue_ids=ResList)
+limit = StructMotifQuery(
+    entry_id="2MNR",
+    limit=100,
+    residue_ids=ResList
+)
 list(limit())
 
 # specifying atom pairing scheme, default = "SIDE_CHAIN"
 # ENUM: "ALL", "BACKBONE", "SIDE_CHAIN", "PSUEDO_ATOMS"
 # this is typechecked by a literal. 
 # Which atoms to consider to compute RMSD scores and transformations. 
-atom = StructMotifQuery(entry_id="2MNR", atom_pairing_scheme="ALL", residue_ids=ResList)
+atom = StructMotifQuery(
+    entry_id="2MNR",
+    atom_pairing_scheme="ALL",
+    residue_ids=ResList)
 list(atom())
 
 # specifying motif pruning strategy, default = "KRUSKAL"
 # ENUM: "NONE", "KRUSKAL"
 # this is typechecked by a literal in the package. 
-# Specifies how many query motifs are "pruned". KRUSKAL leads to less stringent queries, and faster results.
-pruning = StructMotifQuery(entry_id="2MNR", motif_pruning_strategy="NONE", residue_ids=ResList)
+# Specifies how many query motifs are "pruned".
+# KRUSKAL leads to less stringent queries, and faster results.
+pruning = StructMotifQuery(
+    entry_id="2MNR",
+    motif_pruning_strategy="NONE",
+    residue_ids=ResList)
 list(pruning())
 
 # specifying allowed structures, default excluded
 # specify the structures you wish to allow in the return result. As an example,
 # we could only allow the results from the limited query we ran earlier. 
-allowed = StructMotifQuery(entry_id="2MNR", allowed_structures=list(limit()), residue_ids=ResList)
+allowed = StructMotifQuery(
+    entry_id="2MNR",
+    allowed_structures=list(limit()),
+    residue_ids=ResList)
 list(allowed())
 
 # specifying structures to exclude, default excluded
 # specify structures to exclude from a query. We could, for example,
 # exclude the results of the previous allowed query. 
-excluded = StructMotifQuery(entry_id="2MNR", excluded_structures=list(allowed()), residue_ids=ResList)
+excluded = StructMotifQuery(
+    entry_id="2MNR",
+    excluded_structures=list(allowed()),
+    residue_ids=ResList
+)
 list(excluded())
 ```
 The Structure Motif Query can be used to make some very specific queries. Below is an example of a query that retrives occurances of the enolase superfamily, a group of proteins diverse in sequence and structure that are all capable of abstracting a proton from a carboxylic acid. Position-specific exchanges are crucial to represent this superfamily accurately.
@@ -410,7 +313,7 @@ query = StructMotifQuery(entry_id="2MNR", residue_ids=ResList)
 
 list(query())
 ```
-### Chemical Similarity Query
+## Chemical Similarity Search Examples
 
 When you have unique chemical information (e.g., a chemical formula or descriptor) you can use this information to find chemical components (e.g., drugs, inhibitors, modified residues, or building blocks such as amino acids, nucleotides, or sugars), so that it is similar to the formula or descriptor used in the query (perhaps one or two atoms/groups are different), is part of a larger molecule (i.e., the specified formula/descriptor is a substructure), or is exactly or very closely matches the formula or descriptor used in the query. 
 
@@ -437,7 +340,8 @@ Below is are two examples of using query option descriptor. Both descriptor type
 ```python
 from rcsbsearchapi.search import ChemSimilarityQuery
 
-# Query with type = descriptor, descriptor type = SMILES, match type = similar ligands (sterospecific) or graph-relaxed-stereo
+# Query with type = descriptor, descriptor type = SMILES,
+# match type = similar ligands (sterospecific) or graph-relaxed-stereo
 q2 = ChemSimilarityQuery(value="Cc1c(sc[n+]1Cc2cnc(nc2N)C)CCO",
                          query_type="descriptor",
                          descriptor_type="SMILES",
@@ -447,112 +351,221 @@ list(q2())
 ```python
 from rcsbsearchapi.search import ChemSimilarityQuery
 
-# Query with type = descriptor, descriptor type = InChI, match type = substructure (sterospecific) or sub-struct-graph-relaxed-stereo
+# Query with type = descriptor, descriptor type = InChI,
+# match type = substructure (sterospecific) or sub-struct-graph-relaxed-stereo
 q3 = ChemSimilarityQuery(value="InChI=1S/C13H10N2O4/c16-10-6-5-9(11(17)14-10)15-12(18)7-3-1-2-4-8(7)13(15)19/h1-4,9H,5-6H2,(H,14,16,17)/t9-/m0/s1",
                          query_type="descriptor",
                          descriptor_type="InChI",
                          match_type="sub-struct-graph-relaxed-stereo")
 list(q3())
 ```
-### Faceted Query Examples
+## Faceted Query Examples
 In order to group and perform calculations and statistics on PDB data by using a simple search query, you can use a faceted query (or facets). Facets arrange search results into categories (buckets) based on the requested field values. More information on Faceted Queries can be found [here](https://search.rcsb.org/#using-facets). All facets should be provided with `name`, `aggregation_type`, and `attribute` values. Depending on the aggregation type, other parameters must also be specified. The `facets()` function runs the query `q` using the specified facet(s), and returns a list of dictionaries:
 ```python
 from rcsbsearchapi.search import AttributeQuery, Facet, Range
 
-q = AttributeQuery("rcsb_accession_info.initial_release_date", operator="greater", value="2019-08-20")
+q = AttributeQuery(
+    attribute="rcsb_accession_info.initial_release_date",
+    operator="greater",
+    value="2019-08-20")
 q.facets(facets=Facet(name="Methods", aggregation_type="terms", attribute="exptl.method"))
 ```
 
-#### Term Facets
+### Terms Facets
 Terms faceting is a multi-bucket aggregation where buckets are dynamically built - one per unique value. We can specify the minimum count (`>= 0`) for a bucket to be returned using the parameter `min_interval_population` (default value `1`). We can also control the number of buckets returned (`<= 65336`) using the parameter `max_num_intervals` (default value `65336`).
 ```python
-# This is the default query, used by the RCSB Search API when no query is explicitly specified.
-# This default query will be used for most of the examples found below for faceted queries.
-base_q = AttributeQuery("rcsb_entry_info.structure_determination_methodology", operator="exact_match", value="experimental") 
-
-base_q.facets(facets=Facet(name="Journals", aggregation_type="terms", attribute="rcsb_primary_citation.rcsb_journal_abbrev", min_interval_population=1000))
-```
+from rcsbsearchapi.search import AttributeQuery, Facet, Range
 
-#### Histogram Facets
+# This is the default query used by the RCSB Search API when no query is specified.
+# This default query will be used for most of the examples found below for faceted queries.
+base_q = AttributeQuery(
+    attribute="rcsb_entry_info.structure_determination_methodology",
+    operator="exact_match",
+    value="experimental") 
+
+base_q.facets(
+    facets=Facet(
+        name="Journals",
+        aggregation_type="terms",
+        attribute="rcsb_primary_citation.rcsb_journal_abbrev",
+        min_interval_population=1000
+    )
+)
+```
+
+### Histogram Facets
 Histogram facets build fixed-sized buckets (intervals) over numeric values. The size of the intervals must be specified in the parameter `interval`. We can also specify `min_interval_population` if desired.
 ```python
-base_q.facets(return_type="polymer_entity", facets=Facet(name="Formula Weight", aggregation_type="histogram", attribute="rcsb_polymer_entity.formula_weight", interval=50, min_interval_population=1))
-```
-
-#### Date Histogram Facets
-Similar to histogram facets, date histogram facetes build buckets over date values. For date histogram aggregations, we must specify `interval="year"`. Again, we may also specify `min_interval_population`.
-```python
-base_q.facets(facets=Facet(name="Release Date", aggregation_type="date_histogram", attribute="rcsb_accession_info.initial_release_date", interval="year", min_interval_population=1))
-```
-
-#### Range Facets
+base_q.facets(
+    return_type="polymer_entity",
+    facets=Facet(
+        name="Formula Weight",
+        aggregation_type="histogram",
+        attribute="rcsb_polymer_entity.formula_weight",
+        interval=50,
+        min_interval_population=1
+    )
+)
+```
+
+### Date Histogram Facets
+Similar to histogram facets, date histogram facets build buckets over date values. For date histogram aggregations, we must specify `interval="year"`. Again, we may also specify `min_interval_population`.
+```python
+base_q.facets(
+    facets=Facet(
+        name="Release Date",
+        aggregation_type="date_histogram",
+        attribute="rcsb_accession_info.initial_release_date",
+        interval="year",
+        min_interval_population=1
+    )
+)
+```
+
+### Range Facets
 We can define the buckets ourselves by using range facets. In order to specify the ranges, we use the `Range` class. Note that the range includes the `start` value and excludes the `end` value (`include_lower` and `include_upper` should not be specified). If the `start` or `end` is omitted, the minimum or maximum boundaries will be used by default. The buckets should be provided as a list of `Range` objects to the `ranges` parameter.  
 ```python
-base_q.facets(facets=Facet(name="Resolution Combined", aggregation_type="range", attribute="rcsb_entry_info.resolution_combined", ranges=[Range(start=None,end=2), Range(start=2, end=2.2), Range(start=2.2, end=2.4), Range(start=4.6, end=None)]))
+base_q.facets(
+    facets=Facet(
+        name="Resolution Combined",
+        aggregation_type="range",
+        attribute="rcsb_entry_info.resolution_combined",
+        ranges=[Range(start=None,end=2),
+        Range(start=2, end=2.2),
+        Range(start=2.2, end=2.4),
+        Range(start=4.6, end=None)]
+    )
+)
 ```
 
-#### Date Range Facets
+### Date Range Facets
 Date range facets allow us to specify date values as bucket ranges, using [date math expressions](https://search.rcsb.org/#date-math-expressions).
 ```python
-base_q.facets(facets=Facet(name="Release Date", aggregation_type="date_range", attribute="rcsb_accession_info.initial_release_date", ranges=[Range(start=None,end="2020-06-01||-12M"), Range(start="2020-06-01", end="2020-06-01||+12M"), Range(start="2020-06-01||+12M", end=None)]))
-```
-
-#### Cardinality Facets 
+base_q.facets(
+    facets=Facet(
+        name="Release Date",
+        aggregation_type="date_range",
+        attribute="rcsb_accession_info.initial_release_date",
+        ranges=[
+            Range(start=None,end="2020-06-01||-12M"),
+            Range(start="2020-06-01", end="2020-06-01||+12M"),
+            Range(start="2020-06-01||+12M", end=None)
+        ]
+    )
+)
+```
+
+### Cardinality Facets 
 Cardinality facets return a single value: the count of distinct values returned for a given field. A `precision_threshold` (`<= 40000`, default value `40000`) may be specified.
 ```python
-base_q.facets(facets=Facet(name="Organism Names Count", aggregation_type="cardinality", attribute="rcsb_entity_source_organism.ncbi_scientific_name"))
+base_q.facets(
+    facets=Facet(
+        name="Organism Names Count",
+        aggregation_type="cardinality",
+        attribute="rcsb_entity_source_organism.ncbi_scientific_name"))
 ```
 
-#### Multidimensional Facets
+### Multidimensional Facets
 Complex, multi-dimensional aggregations are possible by specifying additional facets in the `nested_facets` parameter, as in the example below:
 ```python
-f1 = Facet(name="Polymer Entity Types", aggregation_type="terms", attribute="rcsb_entry_info.selected_polymer_entity_types")
-f2 = Facet(name="Release Date", aggregation_type="date_histogram", attribute="rcsb_accession_info.initial_release_date", interval="year")
-base_q.facets(facets=Facet(name="Experimental Method", aggregation_type="terms", attribute="rcsb_entry_info.experimental_method", nested_facets=[f1, f2]))
-```
-
-#### Filter Facets
+f1 = Facet(
+    name="Polymer Entity Types",
+    aggregation_type="terms",
+    attribute="rcsb_entry_info.selected_polymer_entity_types"
+)
+f2 = Facet(
+    name="Release Date",
+    aggregation_type="date_histogram",
+    attribute="rcsb_accession_info.initial_release_date",
+    interval="year"
+)
+base_q.facets(
+    facets=Facet(
+        name="Experimental Method",
+        aggregation_type="terms",
+        attribute="rcsb_entry_info.experimental_method",
+        nested_facets=[f1, f2]
+    )
+)
+```
+
+### Filter Facets
 Filters allow us to filter documents that contribute to bucket count. Similar to queries, we can group several `TerminalFilter`s into a single `GroupFilter`. We can combine a filter with a facet using the `FilterFacet` class. Terminal filters should specify an `attribute` and `operator`, as well as possible a `value` and whether or not it should be a `negation` and/or `case_sensitive`. Group filters should specify a `logical_operator` (which should be either `"and"` or `"or"`) and a list of filters (`nodes`) that should be combined. Finally, the `FilterFacet` should be provided with a filter and a (list of) facet(s). Here are some examples:
 ```python
 from rcsbsearchapi.search import TerminalFilter, GroupFilter, FilterFacet
-tf1 = TerminalFilter(attribute="rcsb_polymer_instance_annotation.type", operator="exact_match", value="CATH")
-tf2 = TerminalFilter(attribute="rcsb_polymer_instance_annotation.annotation_lineage.id", operator="in", value=["2.140.10.30", "2.120.10.80"])
-ff2 = FilterFacet(filters=tf2, facets=Facet("CATH Domains", "terms", "rcsb_polymer_instance_annotation.annotation_lineage.id", min_interval_population=1))
+
+tf1 = TerminalFilter(
+    attribute="rcsb_polymer_instance_annotation.type",
+    operator="exact_match",
+    value="CATH"
+)
+tf2 = TerminalFilter(
+    attribute="rcsb_polymer_instance_annotation.annotation_lineage.id",
+    operator="in",
+    value=["2.140.10.30", "2.120.10.80"]
+)
+ff2 = FilterFacet(
+    filters=tf2,
+    facets=Facet(
+        name="CATH Domains",
+        aggregation_type="terms",
+        attribute="rcsb_polymer_instance_annotation.annotation_lineage.id",
+        min_interval_population=1
+    )
+)
 ff1 = FilterFacet(filters=tf1, facets=ff2)
 base_q.facets("polymer_instance", ff1)
 
-tf1 = TerminalFilter(attribute="rcsb_struct_symmetry.kind", operator="exact_match", value="Global Symmetry", negation=False)
-f2 = Facet(name="ec_terms", aggregation_type="terms", attribute="rcsb_polymer_entity.rcsb_ec_lineage.id")
-f1 = Facet(name="sym_symbol_terms", aggregation_type="terms", attribute="rcsb_struct_symmetry.symbol", nested_facets=f2)
+
+tf1 = TerminalFilter(
+    attribute="rcsb_struct_symmetry.kind",
+    operator="exact_match",
+    value="Global Symmetry",
+    negation=False
+)
+f2 = Facet(
+    name="ec_terms",
+    aggregation_type="terms",
+    attribute="rcsb_polymer_entity.rcsb_ec_lineage.id"
+)
+f1 = Facet(
+    name="sym_symbol_terms",
+    aggregation_type="terms",
+    attribute="rcsb_struct_symmetry.symbol",
+    nested_facets=f2
+)
+
 ff = FilterFacet(filters=tf1, facets=f1)
-q1 = AttributeQuery("rcsb_assembly_info.polymer_entity_count", operator="equals", value=1)
-q2 = AttributeQuery("rcsb_assembly_info.polymer_entity_instance_count", operator="greater", value=1)
+q1 = AttributeQuery(
+    attribute="rcsb_assembly_info.polymer_entity_count",
+    operator="equals",
+    value=1
+)
+q2 = AttributeQuery(
+    attribute="rcsb_assembly_info.polymer_entity_instance_count",
+    operator="greater",
+    value=1
+)
 q = q1 & q2
 q.facets("assembly", ff)
 
-tf1 = TerminalFilter(attribute="rcsb_polymer_entity_group_membership.aggregation_method", operator="exact_match", value="sequence_identity")
-tf2 = TerminalFilter(attribute="rcsb_polymer_entity_group_membership.similarity_cutoff", operator="equals", value=100)
+tf1 = TerminalFilter(
+    attribute="rcsb_polymer_entity_group_membership.aggregation_method",
+    operator="exact_match",
+    value="sequence_identity"
+)
+tf2 = TerminalFilter(
+    attribute="rcsb_polymer_entity_group_membership.similarity_cutoff",
+    operator="equals",
+    value=100)
 gf = GroupFilter(logical_operator="and", nodes=[tf1, tf2])
-ff = FilterFacet(filters=gf, facets=Facet("Distinct Protein Sequence Count", "cardinality", "rcsb_polymer_entity_group_membership.group_id"))
+ff = FilterFacet(
+    filters=gf,
+    facets=Facet(
+        "Distinct Protein Sequence Count",
+        "cardinality",
+        "rcsb_polymer_entity_group_membership.group_id"
+    )
+)
 base_q.facets("polymer_entity", ff)
-```
-
-## Sessions
-
-The result of executing a query (either by calling it or using `exec()`) is a
-`Session` object. It implements `__iter__`, so it is usually treated just as an
-iterator of IDs.
-
-Paging is handled transparently by the session, with additional API requests made
-lazily as needed. The page size can be controlled with the `rows` parameter.
-```python
-first = next(iter(query(rows=1)))
-```
-### Progress Bar
-
-The `Session.iquery()` method provides a progress bar indicating the number of API
-requests being made. It requires the `tqdm` package be installed to track the
-progress of the query interactively.
-```python
-results = query().iquery()
-```
+```
\ No newline at end of file
diff --git a/docs/attributes.md b/docs/attributes.md
new file mode 100644
index 0000000..67ae4f2
--- /dev/null
+++ b/docs/attributes.md
@@ -0,0 +1,43 @@
+# Exploring Schema Attributes
+
+Attributes are pieces of information associated with a PDB structure that can be searched for or compared to a value using an [`AttributeQuery`](quickstart.md#getting-started). There are [structure attributes](https://search.rcsb.org/structure-search-attributes.html) and [chemical attributes](https://search.rcsb.org/chemical-search-attributes.html), which are both stored in `rcsb_attributes`. This can be imported as shown below:
+
+```python
+# import rcsb_attributes as attrs for a shorter name
+from rcsbsearchapi import rcsb_attributes as attrs
+```
+
+There are several helpful methods to search for attribute names or explore other information related to attributes.
+
+### search()
+Given a string, this method will return an iterable of `Attr` objects with names that contain the given string. You can also use [regular expression (regex)](https://en.wikipedia.org/wiki/Regular_expression) strings.
+
+```python
+matching_attrs = attrs.search("author")
+
+for attr in matching_attrs:
+    print(attr)
+```
+
+### get_attribute_details()
+Given a full or partial attribute name, return a set of an `Attr` or associated `Attr`s with attribute names, search service types, and descriptions.
+
+```python
+from rcsbsearchapi import rcsb_attributes as attrs
+
+# Use a full name to get details for a specific attribute
+print(attrs.get_attribute_details("rcsb_entity_source_organism.scientific_name"))
+
+# Use a partial name to get the details of all attributes associated with that name
+# Below code gets details for ".common_name", ".source_type", etc in addition to ".scientific_name"
+print(attrs.get_attribute_details("rcsb_entity_source_organism"))
+```
+
+### get_attribute_type()
+Given a full attribute name, return the search service type ("text" for structure attributes and "text_chem" for chemical attributes).
+
+```python
+from rcsbsearchapi import rcsb_attributes as attrs
+ 
+print(attrs.get_attribute_type("rcsb_entity_source_organism.scientific_name"))
+```
\ No newline at end of file
diff --git a/docs/index.md b/docs/index.md
index cc786d0..0f58e47 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -8,7 +8,9 @@ caption: Contents
 maxdepth: 2
 ---
 quickstart.md
-queries.md
+query_construction.md
+attributes.md
+additional_examples.md
 api.rst
 ```
 
diff --git a/docs/query_construction.md b/docs/query_construction.md
new file mode 100644
index 0000000..cc920cd
--- /dev/null
+++ b/docs/query_construction.md
@@ -0,0 +1,624 @@
+# Query Construction
+
+## Query Syntax and Execution
+Two syntaxes are available for constructing queries: an "operator" syntax using Python's
+comparators, and a "fluent" syntax where terms are chained together. Which to use is a
+matter of preference, and both construct the same query object.
+
+### Operator Syntax
+Searches are built up from a series of `Terminal` nodes, which compare structural
+attributes to some search value. In the operator syntax, Python's comparator
+operators are used to construct the comparison. The operators are overloaded to
+return `Terminal` objects for the comparisons.
+
+Here is an example from the [RCSB PDB Search API](http://search.rcsb.org/#search-example-1) page created using the operator syntax. 
+This query finds symmetric dimers having a twofold rotation with the DNA-binding domain of a heat-shock transcription factor.
+```python
+from rcsbsearchapi.search import TextQuery
+from rcsbsearchapi import rcsb_attributes as attrs
+
+# Create terminals for each query
+q1 = TextQuery("heat-shock transcription factor")
+q2 = attrs.rcsb_struct_symmetry.symbol == "C2"
+q3 = attrs.rcsb_struct_symmetry.kind == "Global Symmetry"
+q4 = attrs.rcsb_entry_info.polymer_entity_count_DNA >= 1
+```
+Attributes are available from the `rcsb_attributes` object and can be tab-completed. 
+They can additionally be constructed from strings using the `Attr` (attribute) constructor. 
+
+List of supported comparative operators:
+
+|Operator|Description             |
+|--------|------------------------|
+|==      |is                      |
+|!=      |is not                  |
+|>       |greater than            |
+|>=      |greater than or equal to|
+|<       |less than               |
+|<=      |less than or equal to   |
+|in      |contains phrase or contains words|
+
+To use the `exists` operator, create an [AttributeQuery](quickstart.md#attribute-search)
+
+For methods to search and find details on attributes within this package, go to the [attributes page](attributes.md)
+For a full list of attributes, please refer to the [RCSB PDB schema](http://search.rcsb.org/rcsbsearch/v2/metadata/schema).
+
+Individual `Terminal`s are combined into `Group`s using python's bitwise operators. This is
+analogous to how bitwise operators act on python `set` objects. The operators are
+lazy and won't perform the search until the query is executed.
+
+```python
+query = q1 & (q2 & q3 & q4)  # AND of all queries
+```
+AND (`&`), OR (`|`), and terminal negation (`~`) are implemented directly by the API,
+but the python package also implements set difference (`-`), symmetric difference (`^`),
+and general negation by transforming the query.
+
+List of supported bitwise operators:
+
+|Operator|Description             |
+|--------|------------------------|
+|&       |AND                     |
+|\|      |OR                      |
+|~       |NOT                     |
+|^       |XOR/symmetric difference|
+|-       |set difference          |
+
+
+Queries are executed by calling them as functions. They return an iterator of result
+identifiers.
+
+```python
+# Call the query to execute it
+results = query()
+
+for rid in results:
+    print(rid)
+```
+
+By default, the query will return "entry" results (PDB IDs). It is also possible to
+query other types of results (see [return-types](http://search.rcsb.org/#return-type)
+for options):
+
+```python
+# Set return_type to "assembly" when executing
+results = query(return_type="assembly")
+
+for assembly_id in results:
+    print(assembly_id)
+```
+
+### Fluent Syntax
+The operator syntax is great for simple queries, but requires parentheses or
+temporary variables for complex nested queries. In these cases the fluent syntax may
+be clearer. Queries are built up by appending operations sequentially.
+
+Here is the same example using the fluent syntax
+
+```python
+from rcsbsearchapi.search import TextQuery, AttributeQuery, Attr
+
+# Start with a Attr or TextQuery, then add terms
+results = TextQuery("heat-shock transcription factor").and_(
+    # Add attribute node as fully-formed AttributeQuery
+    AttributeQuery(attribute="rcsb_struct_symmetry.symbol", operator="exact_match", value="C2") \
+
+    # Add attribute node as Attr with chained operations
+    # Setting type to "text" specifies that it's a Structure Attribute
+    .and_(Attr(attribute="rcsb_struct_symmetry.kind", type="text")).exact_match("Global Symmetry") \
+
+    # Add attribute node by name (converted to Attr) with chained operations
+    .and_("rcsb_entry_info.polymer_entity_count_DNA").greater_or_equal(1) \
+
+    # Execute the query and return assembly ids
+    ).exec(return_type="assembly")
+
+# Exec produces an iterator of IDs
+for assembly_id in results:
+    print(assembly_id)
+```
+
+### Grouping Sub-Queries
+Grouping of Structural Attribute and Chemical Attribute queries is permitted. More details on attributes that are available for attribute searches can be found on the [RCSB PDB Search API](https://search.rcsb.org/#search-attributes) page.
+
+```python
+from rcsbsearchapi.search import AttributeQuery
+
+# Query for structures determined by electron microscopy
+q1 = AttributeQuery(
+    attribute="exptl.method",
+    operator="exact_match",
+    value="electron microscopy"
+)
+
+# Drugbank annotations contain phrase "tylenol"
+q2 = AttributeQuery(
+    attribute="drugbank_info.brand_names",
+    operator="contains_phrase",
+    value="tylenol"
+)
+
+# Combine queries with AND
+query = q1 & q2
+
+list(query())
+```
+
+### Sessions
+The result of executing a query (either by calling it or using `exec()`) is a
+`Session` object. It implements `__iter__`, so it is usually treated just as an
+iterator of IDs.
+
+Paging is handled transparently by the session, with additional API requests made
+lazily as needed. The page size can be controlled with the `rows` parameter.
+```python
+first = next(iter(query(rows=1)))
+```
+#### Query Editor Link
+`Session.rcsb_query_editor_url()` will print a link to the [Search API query editor](https://search.rcsb.org/query-editor.html) populated with the query.
+
+```python
+from rcsbsearchapi import AttributeQuery
+
+query = AttributeQuery("exptl.method", operator="exact_match", value="electron microscopy")
+session = query()
+session.rcsb_query_editor_url()
+```
+
+#### Advanced Search Query Builder Link
+`Session.rcsb_query_builder_url()` will print a link to the [Advanced Search Query Builder](https://www.rcsb.org/search/advanced) populated with the query.
+
+```python
+from rcsbsearchapi import AttributeQuery
+
+query = AttributeQuery("exptl.method", operator="exact_match", value="electron microscopy")
+session = query()
+session.rcsb_query_builder_url()
+```
+
+#### Progress Bar
+The `Session.iquery()` method provides a progress bar indicating the number of API
+requests being made. It requires the `tqdm` package be installed to track the
+progress of the query interactively.
+```python
+results = query().iquery()
+```
+
+
+## Query Options
+### Return Types
+A search query can return different result types when a return type is specified. Below are Structure Attribute query examples specifying return types Polymer Entities, Non-polymer Entities, Polymer Instances, and Molecular Definitions. More information on return types can be found in the [RCSB PDB Search API](https://search.rcsb.org/#building-search-request) page.
+
+```python
+from rcsbsearchapi.search import AttributeQuery
+
+# query for 4HHB deoxyhemoglobin
+q1 = AttributeQuery(
+    attribute="rcsb_entry_container_identifiers.entry_id",
+    operator="in",
+    value=["4HHB"]
+)
+
+# Polymer entities
+for poly in q1(return_type="polymer_entity"):
+    print(poly)
+    
+# Non-polymer entities
+for nonPoly in q1(return_type="non_polymer_entity"):
+    print(nonPoly)
+    
+# Polymer instances
+for polyInst in q1(return_type="polymer_instance"):
+    print(polyInst)
+    
+# Molecular definitions
+for mol in q1(return_type="mol_definition"):
+    print(mol)
+```
+
+### Computed Structure Models
+The [RCSB PDB Search API](https://search.rcsb.org/#results_content_type) page provides information on how to include Computed Structure Models (CSMs) into a search query. Here is a code example below.
+
+This query returns IDs for experimental and computed structure models associated with "hemoglobin". Queries for *only* computed models or *only* experimental models can also be made (default).
+```python
+from rcsbsearchapi.search import TextQuery
+
+q1 = TextQuery(value="hemoglobin")
+
+# add parameter as a list with either "computational" or "experimental" or both
+q2 = q1(return_content_type=["computational", "experimental"])
+
+list(q2)
+```
+
+### Result Verbosity
+Results can be returned alongside additional metadata, including result scores. To return this metadata, set the `results_verbosity` parameter to "verbose" (all metadata), "minimal" (scores only), or "compact" (default, no metadata). If set to "verbose" or "minimal", results will be returned as a list of dictionaries. 
+
+For example, here we get all experimental models associated with "hemoglobin", along with their scores.
+
+```python
+from rcsbsearchapi.search import TextQuery
+
+q1 = TextQuery(value="hemoglobin")
+for idscore in list(q1(results_verbosity="minimal")):
+    print(idscore)
+```
+
+
+## Search Service Types
+The list of supported search service types are listed in the table below.
+
+|Search service                    |QueryType                 |
+|----------------------------------|--------------------------|
+|Full-text                         |`TextQuery()`             |
+|Attribute (structure or chemical) |`AttributeQuery()`        |
+|Sequence similarity               |`SequenceQuery()`         |
+|Sequence motif                    |`SequenceMotifQuery()`    |
+|Structure similarity              |`StructSimilarityQuery()` |
+|Structure motif                   |`StructMotifQuery()`      |
+|Chemical similarity               |`ChemSimilarityQuery()`   |
+
+Learn more about available search services on the [RCSB PDB Search API docs](https://search.rcsb.org/#search-services).
+
+
+### Full-Text Search
+
+To perform a general search for structures associated with the phrase "Hemoglobin", you can create a TextQuery. This does a "full-text" search, which is a general search on text associated with PDB structures or molecular definitions.
+
+```python
+from rcsbsearchapi import TextQuery
+
+# Search for structures associated with the phrase "Hemoglobin"
+query = TextQuery(value="Hemoglobin")
+
+# Execute the query by running it as a function
+results = query()
+
+# Results are returned as an iterator of result identifiers.
+for rid in results:
+    print(rid)
+```
+
+### Structure and Chemical Attribute Search
+You can also search for specific structure or chemical attributes using an `AttributeQuery`.
+
+```python
+from rcsbsearchapi import AttributeQuery
+
+# Construct the query
+query = AttributeQuery(
+    attribute="rcsb_entity_source_organism.scientific_name",
+    operator="exact_match",  # other operators include "contains_phrase" and "exists"
+    value="Homo sapiens"
+)
+results = list(query())  # construct a list from query results
+print(results)
+```
+
+As Structure Attributes and Chemical Attributes are almost all unique, the package is usually able to automatically determine the search `service` required. However, for attributes that are both Structure and Chemical Attributes (e.g., `rcsb_id`), specifying a search service is required (Structure Attribute service: `text`, Chemical Attribute service: `text_chem`).
+```python
+# "rcsb_id" is both a Structure Attribute and Chemical Attribute
+#  so search `service` must be specified
+
+q1 = AttributeQuery(
+    attribute="rcsb_id",
+    operator="exact_match",
+    value="4HHB",
+    service="text"  # "text" specifies Structure Attribute search
+)
+list(q1())
+
+q2 = AttributeQuery(
+    attribute="rcsb_id",
+    operator="exact_match",
+    value="HEM",
+    service="text_chem"  # "text_chem" specifies Chemical Attribute search
+)
+list(q2())
+```
+
+|Arguments  |Required| Description                                 |Default               |
+|-----------|--------|---------------------------------------------|----------------------|
+|attribute  |yes     |Full attribute name                          |                      |
+|operator   |yes     |Operation for query                          |                      |
+|value      |no      |Search term(s)                               |                      |
+|service    |no      |Specify structure or chemical search service |                      |
+|negation   |no      |Indicates if the operator is negated         |False                 |
+
+
+The `operator` can be one of a number of options, depending on the attribute type being queried. For example, `contains_phrase` or `exact_match` can be used to compare the attribute to a value, or the `exists` operator may be used to check if the attribute exists for a given structure. Refer to the [Search Attributes](https://search.rcsb.org/structure-search-attributes.html) and [Chemical Attributes](https://search.rcsb.org/chemical-search-attributes.html) documentation for a full list of attributes and applicable operators.
+
+Alternatively, you can also construct attribute queries with comparative operators (e.g., `==`, `>`, or `<`) using the `rcsb_attributes` object (which also allows for names to be tab-completed):
+
+```python
+from rcsbsearchapi import rcsb_attributes as attrs
+
+# Search for structures from humans
+query = attrs.rcsb_entity_source_organism.scientific_name == "Homo sapiens"
+results = list(query())  # construct a list from query results
+print(results)
+```
+
+The full list of supported comparative operators:
+
+|Operator|Description             |
+|--------|------------------------|
+|==      |is                      |
+|!=      |is not                  |
+|>       |greater than            |
+|>=      |greater than or equal to|
+|<       |less than               |
+|<=      |less than or equal to   |
+|in      |contains phrase or contains words|
+
+
+### Sequence Similarity Search
+Below is an example from the [RCSB PDB Search API](https://search.rcsb.org/#search-example-3) page, using the sequence search function. This query finds macromolecular PDB entities that share 90% sequence identity with GTPase HRas protein from *Gallus gallus* (*Chicken*).
+
+
+```python
+from rcsbsearchapi.search import SequenceQuery
+
+# Use SequenceQuery class and add parameters
+query = SequenceQuery(
+    "MTEYKLVVVGAGGVGKSALTIQLIQNHFVDEYDPTIEDSYRKQVVIDGET" +
+    "CLLDILDTAGQEEYSAMRDQYMRTGEGFLCVFAINNTKSFEDIHQYREQI" +
+    "KRVKDSDDVPMVLVGNKCDLPARTVETRQAQDLARSYGIPYIETSAKTRQ" +
+    "GVEDAFYTLVREIRQHKLRKLNPPDESGPGCMNCKCVIS",
+    evalue_cutoff=1,
+    identity_cutoff=0.9,
+    sequence_type="protein"
+)
+    
+# query("polymer_entity") produces an iterator of IDs with return type - polymer entities
+for polyid in query("polymer_entity"):
+    print(polyid)
+```
+
+|Arguments      |Required| Description                                         |Default               |
+|---------------|--------|-----------------------------------------------------|----------------------|
+|value          |yes     |Protein or nucleotide sequence                       |                      |
+|evalue_cutoff  |no      |Upper cutoff for E-value (lower is more significant) |0.1                   |
+|identity_cutoff|no      |Lower cutoff for sequence identity (0-1)             |0                     |
+|sequence_type  |no      |Type of biological sequence ("protein", "dna", "rna")|"protein"             |
+
+### Sequence Motif Search
+Below is an example from the [RCSB PDB Search API](https://search.rcsb.org/#search-example-6) page, using the sequence motif search function. This query retrives occurences of the His2/Cys2 Zinc Finger DNA-binding domain as represented by its PROSITE signature.
+
+
+
+```python
+from rcsbsearchapi.search import SeqMotifQuery
+
+# Use SeqMotifQuery class and add parameters
+query = SeqMotifQuery(
+    "C-x(2,4)-C-x(3)-[LIVMFYWC]-x(8)-H-x(3,5)-H.",
+    pattern_type="prosite",
+    sequence_type="protein"
+)
+
+# query("polymer_entity") produces an iterator of IDs with return type - polymer entities
+for polyid in query("polymer_entity"):
+    print(polyid)
+```
+
+|Arguments      |Required| Description                                         |Default               |
+|---------------|--------|-----------------------------------------------------|----------------------|
+|value          |yes     |Motif to search                                      |                      |
+|pattern_type   |no      |Motif syntax ("simple", "prosite", "regex")          |"simple"              |
+|sequence_type  |no      |Type of biological sequence ("protein", "dna", "rna")|"protein"             |
+
+See [Sequence Motif Search Examples](additional_examples.md#Sequence-Motif-Search-Examples) for more use cases.
+
+### Structure Similarity Search
+The PDB archive can be queried using the 3D shape of a protein structure. To perform this query, 3D protein structure data must be provided as an input or parameter, A chain ID or assembly ID must be specified, whether the input structure data should be compared to Assemblies or Polymer Entity Instance (Chains) is required, and defining the search type as either strict or relaxed is required. More information on how Structure Similarity Queries work can be found on the [RCSB PDB Structure Similarity Search](https://www.rcsb.org/docs/search-and-browse/advanced-search/structure-similarity-search) page.
+
+```python
+from rcsbsearchapi.search import StructSimilarityQuery
+
+# Basic query:
+# Querying using entry ID and default values:
+# assembly ID "1", operator "strict", target search space "Assemblies"
+q1 = StructSimilarityQuery(entry_id="4HHB")
+
+# Same query but with parameters explicitly specified
+q1 = StructSimilarityQuery(
+    structure_search_type="entry_id",
+    entry_id="4HHB",
+    structure_input_type="assembly_id",
+    assembly_id="1",
+    operator="strict_shape_match",
+    target_search_space="assembly"
+)
+for rid in q1("assembly"):
+    print(rid)
+```
+
+<div style="width: 100%; overflow-x: auto;">
+
+|Arguments              | Description                                                                |Default      |
+|-----------------------|----------------------------------------------------------------------------|-------------|
+|structure_search_type  |How to find given structure ("entry_id", "file_url", "file_path")           |"entry_id"   |
+|entry_id               |If "entry_id" specified, PDB ID or CSM ID                                   |             |
+|file_url               |If "file_url" specified, url to file                                        |             |
+|file_path              |If "file_path" specified, path to file                                      |             |
+|file_format            |If "file_url" or "file_path" specified, type of file (ex: "cif")            |             |
+|structure_input_type   |Type of the given structure                                                 |"assembly_id"|
+|assembly_id            |If input_type is "assembly_id", the assembly id number                      |"1"          |
+|chain_id               |If input_type is "chain_id", the chain id letter                            |             |
+|operator               |Search mode ("strict_shape_match" or "relaxed_shape_match")                 |"strict_shape_match"|
+|target_search_space    |Target objects against which the query will be compared for shape similarity|"assembly"   |
+
+</div>
+
+If you provide an entry_id, you must provide either an assembly_id or chain_id
+
+If you provide a file_url or file_path, you must also provide a file_format.
+
+See [Structure Similarity Search Examples](additional_examples.md#Structure-Similarity-Search-Examples) for more use cases.
+
+### Structure Motif Search
+The PDB Archive can also be queried by using a "motif" found in these 3D structures. To perform this type of query, an entry_id or a file URL/path must be provided, along with residues (which are parts of 3D structures.) This is the bare minimum needed to make a search, but there are lots of other parameters that can be added to a Structure Motif Query (see [full search schema](https://search.rcsb.org/redoc/index.html)).
+
+To make a Structure Motif Query, you must first define anywhere from 2-10 "residues" that will be used in the query. Each individual residue has a Chain ID, Operator, Residue Number, and Exchanges (optional) that can be declared in that order using positonal arguments, or using the "chain_id", "struct_oper_id", and "label_seq_id" to define what parameter you are passing through. All 3 of the required parameters must be included, or the package will throw an AssertionError. 
+
+Each residue can only have a maximum of 4 Exchanges, and each query can only have 16 exchanges total. Violating any of these rules will cause the package to throw an AssertionError. 
+
+Examples of how to instantiate Residues can be found below. These can then be put into a list and passed through to a Structure Motif Query.
+```python
+from rcsbsearchapi.search import StructureMotifResidue
+
+# Construct a Residue with:
+# Chain ID of A, an operator of 1, residue number 192, and Exchanges of "LYS" and "HIS".
+# As for what is a valid "Exchange", the package provides these as a literal,
+# and they should be type checked. 
+Res1 = StructureMotifResidue(
+    struct_oper_id="1",
+    chain_id="A",
+    exchanges=["LYS", "HIS"],  # exchanges are optional
+    label_seq_id=192
+)
+
+Res2 = StructureMotifResidue(
+    struct_oper_id="1",
+    chain_id="A",
+    label_seq_id=162
+)
+
+# After declaring a minimum of 2 and as many as 10 residues,
+# they can be passed into a list for use in the query itself:
+ResList = [Res1, Res2]
+```
+
+From there, these Residues can be used in a query. As stated before, you can only include 2-10 residues in a query. If you fail to provide residues for a query, or provide the wrong amount, the package will throw a ValueError. 
+
+For a Structure Motif Query using an entry_id, the only other necessary value that must be passed into the query is the residue list. The default type of query is an entry_id query. 
+
+As this type of query has a lot of optional parameters, do *not* use positional arguments as more than likely an error will occur. 
+
+Below is an example of a basic entry_id Structure Motif Query, with the residues declared earlier:
+```python
+from rcsbsearchapi.search import StructMotifQuery
+
+q1 = StructMotifQuery(entry_id="2MNR", residue_ids=ResList)
+list(q1())
+```
+
+<div style="width: 100%; overflow-x: auto;">
+
+|Arguments                   | Description                                                      |Default      |
+|----------------------------|------------------------------------------------------------------|-------------|
+|structure_search_type       |How to find given structure ("entry_id", "url", "file_path")      |"entry_id"   |
+|backbone_distance_tolerance |Tolerance for distance between Cα atoms (in Å)                    |1            |
+|side_chain_distance_tolerance|Tolerance for distance between Cβ atoms (in Å)                   |1            |
+|angle_tolerance             |Angle between CαCβ vectors (in multiples of 20 degrees)           |1            |
+|entry_id                    |If "entry_id" specified, PDB ID or CSM ID                         |             |
+|url                         |If "file_url" specified, url to file                              |             |
+|file_path                   |If "file_path" specified, path to file                            |             |
+|file_extension              |If "file_url" specified, type of file linked to (ex: "cif")       |             |
+|residue_ids                 |List of StructureMotifResidue objects                             |             |
+|rmsd_cutoff                 |Upper cutoff for root-mean-square deviation (RMSD) score          |2            |
+|atom_pairing_scheme         |Which atoms to consider to compute RMSD scores and transformations.|"SIDE_CHAIN" |
+|motif_pruning_strategy      |Specifies how query motifs are pruned (i.e. simplified)           |"KRUSKAL"    |
+|allowed_structures          |If the list of structure identifiers is specified, the search will only consider those structures (ex: ["HIS", "LYS"])||
+|excluded_structures         |If the list of structure identifiers is specified, the search will exclude those structures from the search space||
+|limit                       |Stop after accepting this many hits                               |             |
+
+</div>
+
+If you provide an entry_id, the other optional parameters can be ignored.
+
+If you provide a file_url, you must also provide a file_extension.
+
+If you provide a file_path, you must also provide a file_extension.
+
+See [Structure Motif Search Examples](additional_examples.md#Structure-Motif-Search-Examples) for more use cases.
+
+### Chemical Similarity Search
+When you have unique chemical information (e.g., a chemical formula or descriptor) you can use this information to find chemical components (e.g., drugs, inhibitors, modified residues, or building blocks such as amino acids, nucleotides, or sugars), so that it is similar to the formula or descriptor used in the query (perhaps one or two atoms/groups are different), is part of a larger molecule (i.e., the specified formula/descriptor is a substructure), or is exactly or very closely matches the formula or descriptor used in the query. 
+
+The search can also be used to identify PDB structures that include the chemical component(s) which match or are similar to the query. These structures can then be examined to learn about the interactions of the component within the structure. More information on Chemical Similarity Queries can be found on the [RCSB PDB Chemical Similarity Search](https://www.rcsb.org/docs/search-and-browse/advanced-search/chemical-similarity-search) page.
+
+To do a Chemical Similarity query, you must first specify one of two possible query options which are formula and descriptors. Formula allows queries to be made by providing a chemical formula. Descriptors allow you to search by chemical notations for example. Each Query option has its own distinct set of parameters, but both options require a value.
+
+The formula query option comes with a match subset parameter which allows users to search chemical components whose formula exactly match the query or matches any portion of the query. The descriptor query option comes with a descriptor type parameter and match type parameter. The descriptor type parameter specifies what type of descriptor the input value is. There are two options which are SMILES (Simplified Molecular Input Line Entry Specification) and InChI (International Chemical Identifier). The match type parameter has six options which are Similar Ligands (Quick Screen), Similar Ligands (Stereospecific), Similar Ligands (including Stereoisomers), Substructure (Stereospecific), Substructure (including Stereoisomers), and Exact match.
+
+When doing Chemical Similarity Queries in this tool, it is important to note that by default the query option is set to formula and match subset is set to False. An example of how that looks like is below.
+```python
+from rcsbsearchapi.search import ChemSimilarityQuery
+
+# Basic query with default values: query type = formula and match subset = False
+q1 = ChemSimilarityQuery(
+    value="C12 H17 N4 O S",
+    query_type="formula",
+    match_subset=False
+)
+list(q1())
+```
+<div style="width: 100%; overflow-x: auto;">
+
+|Arguments              |Required|Description                                                                             |Default      |
+|-----------------------|--------|----------------------------------------------------------------------------------------|-------------|
+|value                  |yes     |Chemical formula or descriptor (SMILES or InChI)                                        |             |
+|query_type             |no      |"formula" or "descriptor"                                                               |"formula"    |
+|descriptor_type        |no      |If "descriptor", whether it's "SMILES" or "InCHI"                                       |             |
+|match_subset           |no      |If "formula", return chemical components/structures that contain the formula as a subset|False        |
+|match_type             |no      |If "descriptor", type of matches to find and return (see below)                         |             |
+
+</div>
+
+| match_type                        |                                           |
+|-----------------------------------|-------------------------------------------|
+| "graph-relaxed"                   | Similar Ligands (including Stereoisomers) |
+| "graph-relaxed-stereo"            | Similar Ligands (Stereospecific)          |
+| "fingerprint-similarity"          | Similar Ligands (Quick screen)            |
+| "sub-struct-graph-relaxed-stereo" | Substructure (Stereospecific)             |
+| "sub-struct-graph-relaxed"        | Substructure (including Stereoisomers)    |
+| "graph-exact"                     | Exact match                               |
+
+See [Chemical Similarity Search Examples](additional_examples.md#Chemical-Similarity-Search-Examples) for more use cases.
+
+## Count Queries
+### Counting Results
+If only the number of results is desired, the count function can be used. This query returns the number of experimental models associated with "hemoglobin".
+```python
+from rcsbsearchapi.search import TextQuery
+
+q1 = TextQuery(value="hemoglobin")
+
+# As with `query()`, `return_type` and `return_content_type` can be parameters to `count()`
+result_count = q1.count()
+print(result_count)
+```
+
+## Faceted Queries
+In order to group and perform calculations and statistics on PDB data by using a simple search query, you can use a faceted query (or facets). Facets arrange search results into categories (buckets) based on the requested field values. More information on Faceted Queries can be found [here](https://search.rcsb.org/#using-facets). All facets should be provided with `name`, `aggregation_type`, and `attribute` values. Depending on the aggregation type, other parameters must also be specified. The `facets()` function runs the query `q` using the specified facet(s), and returns a list of dictionaries:
+```python
+from rcsbsearchapi.search import AttributeQuery, Facet, Range
+
+q = AttributeQuery(
+    attribute="rcsb_accession_info.initial_release_date",
+    operator="greater",
+    value="2019-08-20"
+)
+
+facet_results = q.facets(
+    facets=Facet(
+        name="Methods",
+        aggregation_type="terms",
+        attribute="exptl.method"
+    )
+)
+print(facet_results)
+```
+
+List of available types of Faceted queries:
+- Terms Facet
+- Histogram Facet
+- Range Facet
+- Date Range Facet
+- Cardinality Facet
+- Multidimensional Facet
+- Filter Facet
+
+See example usage of each of these types of Faceted queries at [Faceted Query Examples](additional_examples.md#Faceted-Query-Examples).
diff --git a/docs/quickstart.md b/docs/quickstart.md
index 2ccc66f..8469d68 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -8,528 +8,102 @@ Get it from PyPI:
 
 Or, download from [GitHub](https://github.com/rcsb/py-rcsbsearchapi)
 
-## Syntax
+## Getting Started
+### Basic Query Construction
 
-Here is a quick example of how the package is used. Two syntaxes are available for
-constructing queries: an "operator" API using python's comparators, and a "fluent"
-syntax where terms are chained together. Which to use is a matter of preference.
+#### Full-text search
+To perform a "full-text" search for structures associated with the term "Hemoglobin", you can create a `TextQuery`:
 
-A runnable jupyter notebook with this example is available in [notebooks/quickstart.ipynb](notebooks/quickstart.ipynb), or can be run online using binder:
-[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/rcsb/py-rcsbsearchapi/master?labpath=notebooks%2Fquickstart.ipynb)
-
-An additional example including a Covid-19 related example is in [notebooks/covid.ipynb](notebooks/covid.ipynb):
-[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/rcsb/py-rcsbsearchapi/master?labpath=notebooks%2Fcovid.ipynb)
-
-### Operator Example
-
-Here is an example from the [RCSB PDB Search API](http://search.rcsb.org/#search-example-1) page, using the operator syntax. 
-This query finds symmetric dimers having a twofold rotation with the DNA-binding
-domain of a heat-shock transcription factor.
 ```python
-from rcsbsearchapi.search import TextQuery
-from rcsbsearchapi import rcsb_attributes as attrs
-
-# Create terminals for each query
-q1 = TextQuery("heat-shock transcription factor")
-q2 = attrs.rcsb_struct_symmetry.symbol == "C2"
-q3 = attrs.rcsb_struct_symmetry.kind == "Global Symmetry"
-q4 = attrs.rcsb_entry_info.polymer_entity_count_DNA >= 1
-
-# combined using bitwise operators (&, |, ~, etc)
-query = q1 & (q2 & q3 & q4)
-
-# Call the query to execute it
-for assemblyid in query("assembly"):
-    print(assemblyid)
-```
-For a full list of attributes, please refer to the [RCSB PDB schema](http://search.rcsb.org/rcsbsearch/v2/metadata/schema).
-
-### Fluent Example
-
-Here is the same example using the fluent syntax
-```python
-from rcsbsearchapi.search import TextQuery, AttributeQuery, Attr
-
-# Start with a Attr or TextQuery, then add terms
-results = TextQuery("heat-shock transcription factor").and_(
-    # Add attribute node as fully-formed AttributeQuery
-    AttributeQuery(attribute="rcsb_struct_symmetry.symbol", operator="exact_match", value="C2") \
-    # Add attribute node as Attr with chained operations
-    .and_(Attr("rcsb_struct_symmetry.kind")).exact_match("Global Symmetry") \
-    # Add attribute node by name (converted to Attr) with chained operations
-    .and_("rcsb_entry_info.polymer_entity_count_DNA").greater_or_equal(1)
-    ).exec("assembly")
-
-# Exec produces an iterator of IDs
-for assemblyid in results:
-    print(assemblyid)
-```
-### Structural Attribute Search and Chemical Attribute Search Combination
-
-Grouping of a Structural Attribute query and Chemical Attribute query is permitted as long as grouping is done correctly and search services are specified accordingly. More details on attributes that are available for text searches can be found on the [RCSB PDB Search API](https://search.rcsb.org/#search-attributes) page.
-```python
-from rcsbsearchapi.const import CHEMICAL_ATTRIBUTE_SEARCH_SERVICE, STRUCTURE_ATTRIBUTE_SEARCH_SERVICE
-from rcsbsearchapi.search import AttributeQuery
-
-# By default, service is set to "text" for structural attribute search
-q1 = AttributeQuery("exptl.method", "exact_match", "electron microscopy",
-                    STRUCTURE_ATTRIBUTE_SEARCH_SERVICE # this constant specifies "text" service
-                    )
+from rcsbsearchapi import TextQuery
 
-# Need to specify chemical attribute search service - "text_chem"
-q2 = AttributeQuery("drugbank_info.brand_names", "contains_phrase", "tylenol",
-                    CHEMICAL_ATTRIBUTE_SEARCH_SERVICE # this constant specifies "text_chem" service
-                    )
+# Search for structures associated with the phrase "Hemoglobin"
+query = TextQuery(value="Hemoglobin")
 
-query = q1 & q2 # combining queries
+# Execute the query by running it as a function
+results = query()
 
-list(query())
+# Results are returned as an iterator of result identifiers.
+for rid in results:
+    print(rid)
 ```
-### Computed Structure Models
 
-The [RCSB PDB Search API](https://search.rcsb.org/#results_content_type) page provides information on how to include Computed Structure Models (CSMs) into a search query. Here is a code example below. This query returns IDs for experimental and computed structure models associated with "hemoglobin". Queries for *only* computed models or *only* experimental models can also be made (default).
-```python
-from rcsbsearchapi.search import TextQuery
-    
-q1 = TextQuery("hemoglobin")
-
-# add parameter as a list with either "computational" or "experimental" or both as list values
-q2 = q1(return_content_type=["computational", "experimental"])
+#### Attribute search
+To perform a search for specific structure or chemical attributes, you can create an `AttributeQuery`.
 
-list(q2)
-```
-### Return Types and Attribute Search
-
-A search query can return different result types when a return type is specified. 
-Below are examples on specifying return types Polymer Entities,
-Non-polymer Entities, Polymer Instances, and Molecular Definitions, using a Structure Attribute query. 
-More information on return types can be found in the 
-[RCSB PDB Search API](https://search.rcsb.org/#building-search-request) page.
 ```python
-from rcsbsearchapi.search import AttributeQuery
+from rcsbsearchapi import AttributeQuery
 
-q1 = AttributeQuery("rcsb_entry_container_identifiers.entry_id", "in", ["4HHB"]) # query for 4HHB deoxyhemoglobin
+# Construct a query searching for structures from humans
+query = AttributeQuery(
+    attribute="rcsb_entity_source_organism.scientific_name",
+    operator="exact_match",  # Other operators include "contains_phrase", "exists", and more
+    value="Homo sapiens"
+)
 
-# Polymer entities
-for poly in q1("polymer_entity"): # include return type as a string parameter for query object
-    print(poly)
-    
-# Non-polymer entities
-for nonPoly in q1("non_polymer_entity"):
-    print(nonPoly)
-    
-# Polymer instances
-for polyInst in q1("polymer_instance"):
-    print(polyInst)
-    
-# Molecular definitions
-for mol in q1("mol_definition"):
-    print(mol)
+# Execute query and construct a list from results
+results = list(query())
+print(results)
 ```
-### Counting Results
 
-If only the number of results is desired, the count function can be used. This query returns the number of experimental models associated with "hemoglobin".
-```python
-from rcsbsearchapi.search import TextQuery
+Refer to the [Search Attributes](https://search.rcsb.org/structure-search-attributes.html) and [Chemical Attributes](https://search.rcsb.org/chemical-search-attributes.html) documentation for a full list of attributes and applicable operators.
 
-q1 = TextQuery("hemoglobin")
+Alternatively, you can also construct attribute queries with comparative operators using the `rcsb_attributes` object (which also allows for names to be tab-completed):
 
-# N.B., Just as shown above for `query()`, `return_type` and `return_content_type` can also be specified as parameters to `count()`
-q1.count()
-```
-### Obtaining Scores for Results
-
-Results can be returned alongside additional metadata, including result scores. To return this metadata, set the `results_verbosity` parameter to "verbose" (all metadata), "minimal" (scores only), or "compact" (default, no metadata). If set to "verbose" or "minimal", results will be returned as a list of dictionaries. For example, here we get all experimental models associated with "hemoglobin", along with their scores.
 ```python
-from rcsbsearchapi.search import TextQuery
-
-q1 = TextQuery("hemoglobin")
-for idscore in list(q1(results_verbosity="minimal")):
-    print(idscore)
-```
-### Protein Sequence Search Example
-
-Below is an example from the [RCSB PDB Search API](https://search.rcsb.org/#search-example-3) page, 
-using the sequence search function.
-This query finds macromolecular PDB entities that share 90% sequence identity with
-GTPase HRas protein from *Gallus gallus* (*Chicken*).
-```python
-from rcsbsearchapi.search import SequenceQuery
-
-# Use SequenceQuery class and add parameters
-results = SequenceQuery("MTEYKLVVVGAGGVGKSALTIQLIQNHFVDEYDPTIEDSYRKQVVIDGET" +
-                        "CLLDILDTAGQEEYSAMRDQYMRTGEGFLCVFAINNTKSFEDIHQYREQI" +
-                        "KRVKDSDDVPMVLVGNKCDLPARTVETRQAQDLARSYGIPYIETSAKTRQ" +
-                        "GVEDAFYTLVREIRQHKLRKLNPPDESGPGCMNCKCVIS", 1, 0.9)
-    
-# results("polymer_entity") produces an iterator of IDs with return type - polymer entities
-for polyid in results("polymer_entity"):
-    print(polyid)
-```
-### Sequence Motif Search Example
-
-Below is an example from the [RCSB PDB Search API](https://search.rcsb.org/#search-example-6) page,
-using the sequence motif search function. 
-This query retrives occurences of the His2/Cys2 Zinc Finger DNA-binding domain as
-represented by its PROSITE signature. 
-```python
-from rcsbsearchapi.search import SeqMotifQuery
-
-# Use SeqMotifQuery class and add parameters
-results = SeqMotifQuery("C-x(2,4)-C-x(3)-[LIVMFYWC]-x(8)-H-x(3,5)-H.",
-                        pattern_type="prosite",
-                        sequence_type="protein")
-
-# results("polymer_entity") produces an iterator of IDs with return type - polymer entities
-for polyid in results("polymer_entity"):
-    print(polyid)
-```
-
-You can also use a regular expression (RegEx) to make a sequence motif search.
-As an example, here is a query for the zinc finger motif that binds Zn in a DNA-binding domain:
-```python
-from rcsbsearchapi.search import SeqMotifQuery
-
-results = SeqMotifQuery("C.{2,4}C.{12}H.{3,5}H", pattern_type="regex", sequence_type="protein")
-
-for polyid in results("polymer_entity"):
-    print(polyid)
-```
-
-You can use a standard amino acid sequence to make a sequence motif search. 
-X can be used to allow any amino acid in that position. 
-As an example, here is a query for SH3 domains:
-```python
-from rcsbsearchapi.search import SeqMotifQuery
-
-# By default, the pattern_type argument is "simple" and the sequence_type argument is "protein".
-results = SeqMotifQuery("XPPXP")  # X is used as a "variable residue" and can be any amino acid. 
-
-for polyid in results("polymer_entity"):
-    print(polyid)
-```
-
-All 3 of these pattern types can be used to search for DNA and RNA sequences as well.
-Demonstrated are 2 queries, one DNA and one RNA, using the simple pattern type:
-```python
-from rcsbsearchapi.search import SeqMotifQuery
-
-# DNA query: this is a query for a T-Box.
-dna = SeqMotifQuery("TCACACCT", sequence_type="dna")
-
-print("DNA results:")
-for polyid in dna("polymer_entity"):
-    print(polyid)
-
-# RNA query: 6C RNA motif
-rna = SeqMotifQuery("CCCCCC", sequence_type="rna")
-print("RNA results:")
-for polyid in rna("polymer_entity"):
-    print(polyid)
-```
-### Structure Similarity Query Example
-
-The PDB archive can be queried using the 3D shape of a protein structure. To perform this query, 3D protein structure data must be provided as an input or parameter, A chain ID or assembly ID must be specified, whether the input structure data should be compared to Assemblies or Polymer Entity Instance (Chains) is required, and defining the search type as either strict or relaxed is required. More information on how Structure Similarity Queries work can be found on the [RCSB PDB Structure Similarity Search](https://www.rcsb.org/docs/search-and-browse/advanced-search/structure-similarity-search) page.
-```python
-from rcsbsearchapi.search import StructSimilarityQuery
-
-# Basic query: querying using entry ID and default values assembly ID "1", operator "strict", and target search space "Assemblies"
-q1 = StructSimilarityQuery(entry_id="4HHB")
-
-# Same example but with parameters explicitly specified
-q1 = StructSimilarityQuery(structure_search_type="entry_id",
-                           entry_id="4HHB",
-                           structure_input_type="assembly_id",
-                           assembly_id="1",
-                           operator="strict_shape_match",
-                           target_search_space="assembly"
-                           )
-for id in q1("assembly"):
-    print(id)
-```
-Below is a more complex example that utilizes chain ID, relaxed search operator, and polymer entity instance or target search space. Specifying whether the input structure
-type is chain id or assembly id is very important. For example, specifying chain ID as the input structure type but inputting an assembly ID can lead to
-an error.
-```python
-from rcsbsearchapi.search import StructSimilarityQuery
-
-# More complex query with entry ID value "4HHB", chain ID "B", operator "relaxed", and target search space "Chains"
-q2 = StructSimilarityQuery(structure_search_type="entry_id",
-                                   entry_id="4HHB",
-                                   structure_input_type="chain_id",
-                                   chain_id="B",
-                                   operator="relaxed_shape_match",
-                                   target_search_space="polymer_entity_instance")
-list(q2())
-```
-Structure similarity queries also allow users to upload a file from their local computer or input a file url from the website to query the PDB archive for similar proteins. The file represents a target protein structure in the file formats "cif", "bcif", "pdb", "cif.gz", or "pdb.gz". If a user wants to use a file url for queries, the user must specify the structure search type, the value (being the url), and the file format of the file. This is also the same case for file upload, except the value is the absolute path leading to the file that is in the local machine. An example for file url is below for 4HHB (hemoglobin).
-```python
-from rcsbsearchapi.search import StructSimilarityQuery
-
-q3 = StructSimilarityQuery(structure_search_type="file_url",
-                           file_url="https://files.rcsb.org/view/4HHB.cif",
-                           file_format="cif")
-list(q3())
-
-# If you want to upload your own structure file for similarity search, you can do so by using the `file_path` parameter:
-q4 = StructSimilarityQuery(structure_search_type="file_upload",
-                           file_path="/PATH/TO/FILE.cif",  # specify local model file path
-                           file_format="cif")
-list(q4())
-```
-
-### Structure Motif Query Examples
-
-The PDB Archive can also be queried by using a "motif" found in these 3D structures. To perform this type of query, an entry_id or a file URL/path must be provided, along with residues (which are parts of 3D structures.) This is the bare minimum needed to make a search, but there are lots of other parameters that can be added to a Structure Motif Query (see [full search schema](https://search.rcsb.org/redoc/index.html)).
-
-To make a Structure Motif Query, you must first define anywhere from 2-10 "residues" that will be used in the query. Each individual residue has a Chain ID, Operator, Residue Number, and Exchanges (optional) that can be declared in that order using positonal arguments, or using the "chain_id", "struct_oper_id", and "label_seq_id" to define what parameter you are passing through. All 3 of the required parameters must be included, or the package will throw an AssertionError. 
-
-Each residue can only have a maximum of 4 Exchanges, and each query can only have 16 exchanges total. Violating any of these rules will cause the package to throw an AssertionError. 
-
-Examples of how to instantiate Residues can be found below. These can then be put into a list and passed through to a Structure Motif Query.
-```python
-from rcsbsearchapi.search import StructureMotifResidue
-
-# construct a Residue with a Chain ID of A, an operator of 1, a residue 
-# number of 192, and Exchanges of "LYS" and "HIS"
-Res1 = StructureMotifResidue("A", "1", 192, ["LYS", "HIS"])
-# as for what is a valid "Exchange", the package provides these as a literal,
-# and they should be type checked. 
-
-# you can also specify the arguments:
-# this query is the same as above. 
-Res2 = StructureMotifResidue(struct_oper_id="1", chain_id="A", exchanges=["LYS", "HIS"], label_seq_id=192)
-
-# after delcaring a minimum of 2 and as many as 10 residues, they can be passed into a list for use in the query itself:
-Res3 = StructureMotifResidue("A", "1", 162)  # exchanges are optional
-
-ResList = [Res1, Res3]
-```
-From there, these Residues can be used in a query. As stated before, you can only include 2 - 10 residues in a query. If you fail to provide residues for a query, or provide the wrong amount, the package will throw a ValueError. 
-
-For a Structure Motif Query using an entry_id, the only other necessary value that must be passed into the query is the residue list. The default type of query is an entry_id query. 
-
-As this type of query has a lot of optional parameters, do *not* use positional arguments as more than likely an error will occur. 
-
-Below is an example of a basic entry_id Structure Motif Query, with the residues declared earlier:
-```python
-from rcsbsearchapi.search import StructMotifQuery
-
-q1 = StructMotifQuery(entry_id="2MNR", residue_ids=ResList)
-list(q1())
-```
-Like with Structure Similarity Queries, a file url or filepath can also be provided to the program. These can take the place of an entry_id. 
-
-For a file url query, you *must* provide both a valid file URL (a string), and the file's file extension (also as a string). Failure to provide these elements correctly will cause the package to throw an AssertionError. 
-
-Below is an example of the same query as above, only this time providing a file url:
-```python
-link = "https://files.rcsb.org/view/2MNR.cif"
-q2 = StructMotifQuery(structure_search_type="file_url", url=link, file_extension="cif", residue_ids=ResList)
-# structure_search_type MUST be provided. A mismatched query type will cause an error. 
-list(q2())
-```
-Like with Structure Similarity Queries, a filepath to a file may also be provided. This file must be a valid file accepted by the search API. A file extension must also be provided with the file upload. 
-
-The query would look something like this:
-```python
-filepath = "/absolute/path/to/file.cif"
-q3 = StructMotifQuery(structure_search_type="file_upload", file_path=filepath, file_extension="cif", residue_ids=ResList)
-
-list(q3())
-```
-There are many additional parameters that Structure Motif Query supports. These include a variety of features such as backbone distance tolerance, side chain distance tolerance, angle tolerance, RMSD cutoff, limits (stop searching after this many hits), atom pairing schemes, motif pruning strategy, allowed structures, and excluded structures. These can be mixed and matched as needed to make accurate and useful queries. All of these have some default value which is used when a parameter isn't provided. These parameters conform to the defaults used by the Search API. 
-
-Below will demonstrate how to define these parameters using non-positional arguments:
-```python
-# specifying backbone distance tolerance: 0-3, default is 1
-# allowed backbone distance tolerance in Angstrom. 
-backbone = StructMotifQuery(entry_id="2MNR", backbone_distance_tolerance=2, residue_ids=ResList)
-list(backbone())
-
-# specifying sidechain distance tolerance: 0-3, default is 1
-# allowed side-chain distance tolerance in Angstrom.
-sidechain = StructMotifQuery(entry_id="2MNR", side_chain_distance_tolerance=2, residue_ids=ResList)
-list(sidechain())
-
-# specifying angle tolerance: 0-3, default is 1
-# allowed angle tolerance in multiples of 20 degrees. 
-angle = StructMotifQuery(entry_id="2MNR", angle_tolerance=2, residue_ids=ResList)
-list(angle())
-
-# specifying RMSD cutoff: >=0, default is 2
-# Threshold above which hits will be filtered by RMSD
-rmsd = StructMotifQuery(entry_id="2MNR", rmsd_cutoff=1, residue_ids=ResList)
-list(rmsd())
-
-# specifying limit: >=0, default excluded
-# Stop accepting results after this many hits. 
-limit = StructMotifQuery(entry_id="2MNR", limit=100, residue_ids=ResList)
-list(limit())
-
-# specifying atom pairing scheme, default = "SIDE_CHAIN"
-# ENUM: "ALL", "BACKBONE", "SIDE_CHAIN", "PSUEDO_ATOMS"
-# this is typechecked by a literal. 
-# Which atoms to consider to compute RMSD scores and transformations. 
-atom = StructMotifQuery(entry_id="2MNR", atom_pairing_scheme="ALL", residue_ids=ResList)
-list(atom())
-
-# specifying motif pruning strategy, default = "KRUSKAL"
-# ENUM: "NONE", "KRUSKAL"
-# this is typechecked by a literal in the package. 
-# Specifies how many query motifs are "pruned". KRUSKAL leads to less stringent queries, and faster results.
-pruning = StructMotifQuery(entry_id="2MNR", motif_pruning_strategy="NONE", residue_ids=ResList)
-list(pruning())
-
-# specifying allowed structures, default excluded
-# specify the structures you wish to allow in the return result. As an example,
-# we could only allow the results from the limited query we ran earlier. 
-allowed = StructMotifQuery(entry_id="2MNR", allowed_structures=list(limit()), residue_ids=ResList)
-list(allowed())
-
-# specifying structures to exclude, default excluded
-# specify structures to exclude from a query. We could, for example,
-# exclude the results of the previous allowed query. 
-excluded = StructMotifQuery(entry_id="2MNR", excluded_structures=list(allowed()), residue_ids=ResList)
-list(excluded())
-```
-The Structure Motif Query can be used to make some very specific queries. Below is an example of a query that retrives occurances of the enolase superfamily, a group of proteins diverse in sequence and structure that are all capable of abstracting a proton from a carboxylic acid. Position-specific exchanges are crucial to represent this superfamily accurately.
-```python
-Res1 = StructureMotifResidue("A", "1", 162, ["LYS", "HIS"])
-Res2 = StructureMotifResidue("A", "1", 193)
-Res3 = StructureMotifResidue("A", "1", 219)
-Res4 = StructureMotifResidue("A", "1", 245, ["GLU", "ASP", "ASN"])
-Res5 = StructureMotifResidue("A", "1", 295, ["HIS", "LYS"])
-
-ResList = [Res1, Res2, Res3, Res4, Res5]
+from rcsbsearchapi import rcsb_attributes as attrs
 
-query = StructMotifQuery(entry_id="2MNR", residue_ids=ResList)
+# Search for structures from humans
+query = attrs.rcsb_entity_source_organism.scientific_name == "Homo sapiens"
 
-list(query())
+# Run query and construct a list from results
+results = list(query())
+print(results)
 ```
-### Chemical Similarity Query
-
-When you have unique chemical information (e.g., a chemical formula or descriptor) you can use this information to find chemical components (e.g., drugs, inhibitors, modified residues, or building blocks such as amino acids, nucleotides, or sugars), so that it is similar to the formula or descriptor used in the query (perhaps one or two atoms/groups are different), is part of a larger molecule (i.e., the specified formula/descriptor is a substructure), or is exactly or very closely matches the formula or descriptor used in the query. 
 
-The search can also be used to identify PDB structures that include the chemical component(s) which match or are similar to the query. These structures can then be examined to learn about the interactions of the component within the structure. More information on Chemical Similarity Queries can be found on the [RCSB PDB Chemical Similarity Search](https://www.rcsb.org/docs/search-and-browse/advanced-search/chemical-similarity-search) page.
+#### Grouping sub-queries
 
-To do a Chemical Similarity query, you must first specify one of two possible query options which are formula and descriptors. Formula allows queries to be made by providing a chemical formula. Descriptors allow you to search by chemical notations for example. Each Query option has its own distinct set of parameters, but both options require a value.
+You can combine multiple queries using Python bitwise operators. 
 
-The formula query option comes with a match subset parameter which allows users to search chemical components whose formula exactly match the query or matches any portion of the query. The descriptor query option comes with a descriptor type parameter and match type parameter. The descriptor type parameter specifies what type of descriptor the input value is. There are two options which are SMILES (Simplified Molecular Input Line Entry Specification) and InChI (International Chemical Identifier). The match type parameter has six options which are Similar Ligands (Quick Screen), Similar Ligands (Stereospecific), Similar Ligands (including Stereoisomers), Substructure (Stereospecific), Substructure (including Stereoisomers), and Exact match.
-
-When doing Chemical Similarity Queries in this tool, it is important to note that by default the query option is set to formula and match subset is set to False. An example of how that looks like is below.
 ```python
-from rcsbsearchapi.search import ChemSimilarityQuery
-
-# Basic query with default values: query type = formula and match subset = False
-q1 = ChemSimilarityQuery(value="C12 H17 N4 O S")
-
-# Same example but with all the parameters listed
-q1 = ChemSimilarityQuery(value="C12 H17 N4 O S",
-                         query_type="formula",
-                         match_subset=False)
-list(q1())
-```
-Below is are two examples of using query option descriptor. Both descriptor type parameters are also used.
-```python
-from rcsbsearchapi.search import ChemSimilarityQuery
-
-# Query with type = descriptor, descriptor type = SMILES, match type = similar ligands (sterospecific) or graph-relaxed-stereo
-q2 = ChemSimilarityQuery(value="Cc1c(sc[n+]1Cc2cnc(nc2N)C)CCO",
-                         query_type="descriptor",
-                         descriptor_type="SMILES",
-                         match_type="graph-relaxed-stereo")
-list(q2())
-```
-```python
-from rcsbsearchapi.search import ChemSimilarityQuery
-
-# Query with type = descriptor, descriptor type = InChI, match type = substructure (sterospecific) or sub-struct-graph-relaxed-stereo
-q3 = ChemSimilarityQuery(value="InChI=1S/C13H10N2O4/c16-10-6-5-9(11(17)14-10)15-12(18)7-3-1-2-4-8(7)13(15)19/h1-4,9H,5-6H2,(H,14,16,17)/t9-/m0/s1",
-                         query_type="descriptor",
-                         descriptor_type="InChI",
-                         match_type="sub-struct-graph-relaxed-stereo")
-list(q3())
-```
-### Faceted Query Examples
-In order to group and perform calculations and statistics on PDB data by using a simple search query, you can use a faceted query (or facets). Facets arrange search results into categories (buckets) based on the requested field values. More information on Faceted Queries can be found [here](https://search.rcsb.org/#using-facets). All facets should be provided with `name`, `aggregation_type`, and `attribute` values. Depending on the aggregation type, other parameters must also be specified. The `facets()` function runs the query `q` using the specified facet(s), and returns a list of dictionaries:
-```python
-from rcsbsearchapi.search import AttributeQuery, Facet, Range
-
-q = AttributeQuery("rcsb_accession_info.initial_release_date", operator="greater", value="2019-08-20")
-q.facets(facets=Facet(name="Methods", aggregation_type="terms", attribute="exptl.method"))
-```
-
-#### Term Facets
-Terms faceting is a multi-bucket aggregation where buckets are dynamically built - one per unique value. We can specify the minimum count (`>= 0`) for a bucket to be returned using the parameter `min_interval_population` (default value `1`). We can also control the number of buckets returned (`<= 65336`) using the parameter `max_num_intervals` (default value `65336`).
-```python
-# This is the default query, used by the RCSB Search API when no query is explicitly specified.
-# This default query will be used for most of the examples found below for faceted queries.
-base_q = AttributeQuery("rcsb_entry_info.structure_determination_methodology", operator="exact_match", value="experimental") 
-
-base_q.facets(facets=Facet(name="Journals", aggregation_type="terms", attribute="rcsb_primary_citation.rcsb_journal_abbrev", min_interval_population=1000))
-```
+from rcsbsearchapi import rcsb_attributes as attrs
 
-#### Histogram Facets
-Histogram facets build fixed-sized buckets (intervals) over numeric values. The size of the intervals must be specified in the parameter `interval`. We can also specify `min_interval_population` if desired.
-```python
-base_q.facets(return_type="polymer_entity", facets=Facet(name="Formula Weight", aggregation_type="histogram", attribute="rcsb_polymer_entity.formula_weight", interval=50, min_interval_population=1))
-```
+# Query for human epidermal growth factor receptor (EGFR) structures (UniProt ID P00533)
+#  with investigational or experimental drugs bound
+q1 = attrs.rcsb_polymer_entity_container_identifiers.reference_sequence_identifiers.database_accession == "P00533"
+q2 = attrs.rcsb_entity_source_organism.scientific_name == "Homo sapiens"
+q3 = attrs.drugbank_info.drug_groups == "investigational"
+q4 = attrs.drugbank_info.drug_groups == "experimental"
 
-#### Date Histogram Facets
-Similar to histogram facets, date histogram facetes build buckets over date values. For date histogram aggregations, we must specify `interval="year"`. Again, we may also specify `min_interval_population`.
-```python
-base_q.facets(facets=Facet(name="Release Date", aggregation_type="date_histogram", attribute="rcsb_accession_info.initial_release_date", interval="year", min_interval_population=1))
-```
+# Structures matching UniProt ID P00533 AND from humans
+#  AND (investigational OR experimental drug group)
+query = q1 & q2 & (q3 | q4)
 
-#### Range Facets
-We can define the buckets ourselves by using range facets. In order to specify the ranges, we use the `Range` class. Note that the range includes the `start` value and excludes the `end` value (`include_lower` and `include_upper` should not be specified). If the `start` or `end` is omitted, the minimum or maximum boundaries will be used by default. The buckets should be provided as a list of `Range` objects to the `ranges` parameter.  
-```python
-base_q.facets(facets=Facet(name="Resolution Combined", aggregation_type="range", attribute="rcsb_entry_info.resolution_combined", ranges=[Range(start=None,end=2), Range(start=2, end=2.2), Range(start=2.2, end=2.4), Range(start=4.6, end=None)]))
+# Execute query and print first 10 ids
+results = list(query())
+print(results[:10])
 ```
 
-#### Date Range Facets
-Date range facets allow us to specify date values as bucket ranges, using [date math expressions](https://search.rcsb.org/#date-math-expressions).
-```python
-base_q.facets(facets=Facet(name="Release Date", aggregation_type="date_range", attribute="rcsb_accession_info.initial_release_date", ranges=[Range(start=None,end="2020-06-01||-12M"), Range(start="2020-06-01", end="2020-06-01||+12M"), Range(start="2020-06-01||+12M", end=None)]))
-```
+These examples are in `operator` syntax. You can also make queries in `fluent` syntax. Learn more about both syntaxes and implementation details in [Constructing and Executing Queries](query_construction.md#constructing-and-executing-queries).
 
-#### Cardinality Facets 
-Cardinality facets return a single value: the count of distinct values returned for a given field. A `precision_threshold` (`<= 40000`, default value `40000`) may be specified.
-```python
-base_q.facets(facets=Facet(name="Organism Names Count", aggregation_type="cardinality", attribute="rcsb_entity_source_organism.ncbi_scientific_name"))
-```
+### Supported Search Services
+The list of supported search service types are listed in the table below. For more details on their usage, see [Search Service Types](query_construction.md#search-service-types).
 
-#### Multidimensional Facets
-Complex, multi-dimensional aggregations are possible by specifying additional facets in the `nested_facets` parameter, as in the example below:
-```python
-f1 = Facet(name="Polymer Entity Types", aggregation_type="terms", attribute="rcsb_entry_info.selected_polymer_entity_types")
-f2 = Facet(name="Release Date", aggregation_type="date_histogram", attribute="rcsb_accession_info.initial_release_date", interval="year")
-base_q.facets(facets=Facet(name="Experimental Method", aggregation_type="terms", attribute="rcsb_entry_info.experimental_method", nested_facets=[f1, f2]))
-```
+|Search service                    |QueryType                 |
+|----------------------------------|--------------------------|
+|Full-text                         |`TextQuery()`             |
+|Attribute (structure or chemical) |`AttributeQuery()`        |
+|Sequence similarity               |`SequenceQuery()`         |
+|Sequence motif                    |`SequenceMotifQuery()`    |
+|Structure similarity              |`StructSimilarityQuery()` |
+|Structure motif                   |`StructMotifQuery()`      |
+|Chemical similarity               |`ChemSimilarityQuery()`   |
 
-#### Filter Facets
-Filters allow us to filter documents that contribute to bucket count. Similar to queries, we can group several `TerminalFilter`s into a single `GroupFilter`. We can combine a filter with a facet using the `FilterFacet` class. Terminal filters should specify an `attribute` and `operator`, as well as possible a `value` and whether or not it should be a `negation` and/or `case_sensitive`. Group filters should specify a `logical_operator` (which should be either `"and"` or `"or"`) and a list of filters (`nodes`) that should be combined. Finally, the `FilterFacet` should be provided with a filter and a (list of) facet(s). Here are some examples:
-```python
-from rcsbsearchapi.search import TerminalFilter, GroupFilter, FilterFacet
-tf1 = TerminalFilter(attribute="rcsb_polymer_instance_annotation.type", operator="exact_match", value="CATH")
-tf2 = TerminalFilter(attribute="rcsb_polymer_instance_annotation.annotation_lineage.id", operator="in", value=["2.140.10.30", "2.120.10.80"])
-ff2 = FilterFacet(filters=tf2, facets=Facet("CATH Domains", "terms", "rcsb_polymer_instance_annotation.annotation_lineage.id", min_interval_population=1))
-ff1 = FilterFacet(filters=tf1, facets=ff2)
-base_q.facets("polymer_instance", ff1)
+Learn more about available search services on the [RCSB PDB Search API docs](https://search.rcsb.org/#search-services).
 
-tf1 = TerminalFilter(attribute="rcsb_struct_symmetry.kind", operator="exact_match", value="Global Symmetry", negation=False)
-f2 = Facet(name="ec_terms", aggregation_type="terms", attribute="rcsb_polymer_entity.rcsb_ec_lineage.id")
-f1 = Facet(name="sym_symbol_terms", aggregation_type="terms", attribute="rcsb_struct_symmetry.symbol", nested_facets=f2)
-ff = FilterFacet(filters=tf1, facets=f1)
-q1 = AttributeQuery("rcsb_assembly_info.polymer_entity_count", operator="equals", value=1)
-q2 = AttributeQuery("rcsb_assembly_info.polymer_entity_instance_count", operator="greater", value=1)
-q = q1 & q2
-q.facets("assembly", ff)
+## Jupyter Notebooks
+A runnable jupyter notebook is available in [notebooks/quickstart.ipynb](https://github.com/rcsb/py-rcsbsearchapi/blob/master/notebooks/quickstart.ipynb), or can be run online using Google Colab:
+<a href="https://colab.research.google.com/github/rcsb/py-rcsbsearchapi/blob/master/notebooks/quickstart.ipynb" target="_parent"><img src="https://colab.research.google.com/assets/colab-badge.svg" alt="Open In Colab"/></a>
 
-tf1 = TerminalFilter(attribute="rcsb_polymer_entity_group_membership.aggregation_method", operator="exact_match", value="sequence_identity")
-tf2 = TerminalFilter(attribute="rcsb_polymer_entity_group_membership.similarity_cutoff", operator="equals", value=100)
-gf = GroupFilter(logical_operator="and", nodes=[tf1, tf2])
-ff = FilterFacet(filters=gf, facets=Facet("Distinct Protein Sequence Count", "cardinality", "rcsb_polymer_entity_group_membership.group_id"))
-base_q.facets("polymer_entity", ff)
-```
\ No newline at end of file
+An additional Covid-19 related example is in [notebooks/covid.ipynb](https://github.com/rcsb/py-rcsbsearchapi/blob/master/notebooks/covid.ipynb):
+<a href="https://colab.research.google.com/github//rcsb/py-rcsbsearchapi/blob/master/notebooks/covid.ipynb" target="_parent"><img src="https://colab.research.google.com/assets/colab-badge.svg" alt="Open In Colab"/></a>
diff --git a/notebooks/quickstart.ipynb b/notebooks/quickstart.ipynb
index d591195..67f2bd3 100644
--- a/notebooks/quickstart.ipynb
+++ b/notebooks/quickstart.ipynb
@@ -422,8 +422,8 @@
     "                           operator=\"strict_shape_match\",\n",
     "                           target_search_space=\"assembly\"\n",
     "                           )\n",
-    "for id in q1(\"assembly\"):\n",
-    "    print(id)"
+    "for rid in q1(\"assembly\"):\n",
+    "    print(rid)"
    ]
   },
   {
diff --git a/rcsbsearchapi/__init__.py b/rcsbsearchapi/__init__.py
index 29ae236..355be36 100644
--- a/rcsbsearchapi/__init__.py
+++ b/rcsbsearchapi/__init__.py
@@ -1,53 +1,12 @@
 """RCSB PDB Search API"""
-from typing import TYPE_CHECKING, Any, List
 
-from .search import Terminal  # noqa: F401
-from .search import Attr, Group, Query, Session, TextQuery, Value
+from typing import List
+from .search import Terminal, SCHEMA  # noqa: F401
+from .search import Attr, AttributeQuery, Group, Query, TextQuery
 
 __version__ = "1.6.0"
 
-
-# loading rcsb_attributes can cause errors, so load it lazily
-if TYPE_CHECKING:
-    from .schema import SchemaGroup
-
-
-# Set docstring at top level too. Keep synchronized with schema.rcsb_attributes
-rcsb_attributes: "SchemaGroup"
-"""Object with all known RCSB PDB attributes.
-
-This is provided to ease autocompletion as compared to creating Attr objects from
-strings. For example,
-::
-
-    rcsb_attributes.rcsb_nonpolymer_instance_feature_summary.chem_id
-
-is equivalent to
-::
-
-    Attr('rcsb_nonpolymer_instance_feature_summary.chem_id')
-
-All attributes in `rcsb_attributes` can be iterated over.
-
-    >>> [a for a in rcsb_attributes if "stoichiometry" in a.attribute]
-    [Attr(attribute='rcsb_struct_symmetry.stoichiometry')]
-
-Attributes matching a regular expression can also be filtered:
-
-    >>> list(rcsb_attributes.search('rcsb.*stoichiometry'))
-    [Attr(attribute='rcsb_struct_symmetry.stoichiometry')]a
-
-"""
-
-
-def __getattr__(name: str) -> Any:
-    # delay instantiating rcsb_attributes until it is needed
-    if name == "rcsb_attributes":
-        if "rcsb_attributes" not in globals():
-            from .schema import rcsb_attributes as attrs
-            globals()["rcsb_attributes"] = attrs
-        return globals()["rcsb_attributes"]
-    raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+rcsb_attributes = SCHEMA.rcsb_attributes
 
 
 def __dir__() -> List[str]:
@@ -57,10 +16,9 @@ def __dir__() -> List[str]:
 __all__ = [
     "Query",
     "Group",
+    "Attr",
     "Terminal",
     "TextQuery",
-    "Session",
-    "Attr",
-    "Value",
-    "rcsb_attributes",
+    "AttributeQuery",
+    "rcsb_attributes"
 ]
diff --git a/rcsbsearchapi/const.py b/rcsbsearchapi/const.py
index 247e794..3576d94 100644
--- a/rcsbsearchapi/const.py
+++ b/rcsbsearchapi/const.py
@@ -2,7 +2,9 @@
 STRUCTURE_INDEX = 0
 CHEMICAL_INDEX = 0
 STRUCTURE_ATTRIBUTE_SCHEMA_URL = "http://search.rcsb.org/rcsbsearch/v2/metadata/schema"
+STRUCTURE_ATTRIBUTE_SCHEMA_FILE = "resources/metadata_schema.json"
 CHEMICAL_ATTRIBUTE_SCHEMA_URL = "https://search.rcsb.org/rcsbsearch/v2/metadata/chemical/schema"
+CHEMICAL_ATTRIBUTE_SCHEMA_FILE = "resources/chemical_schema.json"
 SEARCH_SCHEMA_URL = "https://search.rcsb.org/schema/search/request/json-schema-rcsb_search_query.json"
 SEARCH_OPENAPI_SCHEMA_URL = "https://search.rcsb.org/openapi.json"
 STRUCTURE_ATTRIBUTE_SEARCH_SERVICE = "text"
diff --git a/rcsbsearchapi/schema.py b/rcsbsearchapi/schema.py
index d889176..9950b04 100644
--- a/rcsbsearchapi/schema.py
+++ b/rcsbsearchapi/schema.py
@@ -7,170 +7,296 @@
 import logging
 import pkgutil
 import re
+import warnings
+from typing import List, Union
 import requests
-from typing import Any, Iterator, List, Union
-from .search import Attr
-from .const import STRUCTURE_ATTRIBUTE_SCHEMA_URL, CHEMICAL_ATTRIBUTE_SCHEMA_URL, SEARCH_SCHEMA_URL, STRUCTURE_ATTRIBUTE_SEARCH_SERVICE, CHEMICAL_ATTRIBUTE_SEARCH_SERVICE
-
-
-def _fetch_schema(url: str):
-    "Request the current schema from the web"
-    logging.info("Requesting %s", url)
-    response = requests.get(url, timeout=None)
-    if response.status_code == 200:
-        return response.json()
-    else:
-        logging.debug("HTTP response status code %r", response.status_code)
-        return None
-
-
-def _load_json_schema():
-    logging.info("Loading structure schema from file")
-    latest = pkgutil.get_data(__package__, "resources/metadata_schema.json")
-    return json.loads(latest)
-
-
-def _load_chem_schema():
-    logging.info("Loading chemical schema from file")
-    latest = pkgutil.get_data(__package__, "resources/chemical_schema.json")
-    return json.loads(latest)
+from .const import (
+    STRUCTURE_ATTRIBUTE_SCHEMA_URL,
+    CHEMICAL_ATTRIBUTE_SCHEMA_URL,
+    STRUCTURE_ATTRIBUTE_SEARCH_SERVICE,
+    STRUCTURE_ATTRIBUTE_SCHEMA_FILE,
+    CHEMICAL_ATTRIBUTE_SEARCH_SERVICE,
+    CHEMICAL_ATTRIBUTE_SCHEMA_FILE,
+)
 
 
 class SchemaGroup:
     """A non-leaf node in the RCSB PDB schema. Leaves are Attr values."""
 
-    def search(self, pattern: Union[str, re.Pattern], flags=0) -> Iterator[Attr]:
+    def __init__(self, attr_type):
+        self.Attr = attr_type  # Attr or AttrLeaf
+        self._members = {}  # Dictionary to store members
+
+    def search(self, pattern: Union[str, re.Pattern], flags=0):
         """Find all attributes in the schema matching a regular expression.
 
         Returns:
-            An iterator supplying Attr objects whose attribute matches.
+            A list of Attr objects whose attribute matches.
         """
         matcher = re.compile(pattern, flags=flags)
-        return filter(lambda a: matcher.search(a.attribute), self)
-
-    def __iter__(self) -> Iterator[Attr]:
+        filter_match = filter(lambda a: matcher.search(a.attribute), self)
+        return list(filter_match)
+
+    def list(self):
+        """Get a list of full names for all structure and chemical attributes"""
+        all_list = []
+        for attr in self:
+            attr_dict = vars(attr)
+            name = attr_dict["attribute"]
+            all_list.append(name)
+        return all_list
+
+    def __iter__(self):
         """Iterate over all leaf nodes
 
         Example:
-
             >>> [a for a in attrs if "stoichiometry" in a.attribute]
             [Attr(attribute='rcsb_struct_symmetry.stoichiometry')]
-
         """
 
-        def leaves(self):
-            for k, v in self.__dict__.items():
-                if isinstance(v, Attr):
+        def leaves(self, attr_type):
+            for k, v in self._members.items():
+                if isinstance(v, attr_type):
                     yield v
                 elif isinstance(v, SchemaGroup):
                     yield from iter(v)
+                # skips ["Attr"] key in __dict__
+                elif v is attr_type:
+                    continue
                 else:
                     # Shouldn't happen
                     raise TypeError(f"Unrecognized member {k!r}: {v!r}")
-        return leaves(self)
 
-    def __str__(self):
-        return "\n".join((str(c) for c in self.__dict__.values()))
-
-
-def _make_group(fullname: str, nodeL: List) -> Union[SchemaGroup, Attr]:
-    """Represent this node of the schema as a python object
-
-    Params:
-    - name: full dot-separated attribute name
-
-    Returns:
-    An Attr (Leaf nodes) or SchemaGroup (object nodes)
-    """
-    group = SchemaGroup()
-    for (node, attrtype) in nodeL:
-        if "anyOf" in node:
-            children = {_make_group(fullname, [(n, attrtype)]) for n in node["anyOf"]}
-            # Currently only deal with anyOf in leaf nodes
-            assert len(children) == 1, f"type of {fullname} couldn't be determined"
-            return next(iter(children))
-        if "oneOf" in node:
-            children = {_make_group(fullname, [(n, attrtype)]) for n in node["oneOf"]}
-            # Currently only deal with oneOf in leaf nodes
-            assert len(children) == 1, f"type of {fullname} couldn't be determined"
-            return next(iter(children))
-        if "allOf" in node:
-            children = {_make_group(fullname, [(n, attrtype)]) for n in node["allOf"]}
-            # Currently only deal with allOf in leaf nodes
-            assert len(children) == 1, f"type of {fullname} couldn't be determined"
-            return next(iter(children))
-        if node["type"] in ("string", "number", "integer", "date"):
-            return Attr(fullname, attrtype)
-        elif node["type"] == "array":
-            # skip to items
-            return _make_group(fullname, [(node["items"], attrtype)])
-        elif node["type"] == "object":
-            for childname, childnode in node["properties"].items():
-                fullchildname = f"{fullname}.{childname}" if fullname else childname
-                childgroup = _make_group(fullchildname, [(childnode, attrtype)])
-                setattr(group, childname, childgroup)
+        return leaves(self, self.Attr)
+
+    def get_attribute_details(self, attribute: str) -> str:
+        """Return attribute information given full or partial attribute name
+
+        Args:
+            attribute (str): Full attribute name
+                ex: "rcsb_id", "rcsb_entity_source_organism.scientific_name"
+
+        Returns:
+            str: Return corresponding attribute description if there's a match
+        """
+
+        def leaves(d):
+            for v in d.values():
+                if "attribute" in v:
+                    yield v
+                else:
+                    yield from leaves(v)
+
+        split_attr = attribute.split(".")
+        ptr = self  # dictionary of attributes
+        for level in split_attr:
+            if level not in ptr:
+                warnings.warn(f"Attribute path segment '{level}' (for input '{attribute}') not found in schema.", UserWarning)
+                return None
+            ptr = ptr[level]
+        if "attribute" in ptr.__dict__ and getattr(ptr, "attribute") == attribute:  # must be .__dict__ so both SchemaGroup and Attr are compared as dictionaries
+            return ptr
         else:
-            raise TypeError(f"Unrecognized node type {node['type']!r} of {fullname}")
-    return group
+            return {c for c in leaves(ptr)}
 
+    def get_attribute_type(self, attribute: str) -> Union[str, None]:
+        """Return attribute type given full attribute name
 
-def _make_schema(structure_schema_url: str, chemical_schema_url: str) -> SchemaGroup:
-    json1 = _fetch_schema(structure_schema_url)
-    if not json1:
-        json1 = _load_json_schema()
-    json2 = _fetch_schema(chemical_schema_url)
-    if not json2:
-        json2 = _load_chem_schema()
-    schemas = [(json1, STRUCTURE_ATTRIBUTE_SEARCH_SERVICE), (json2, CHEMICAL_ATTRIBUTE_SEARCH_SERVICE)]
-    schema = _make_group("", schemas)
-    assert isinstance(schema, SchemaGroup)  # for type checking
-    return schema
+        Args:
+            attribute (str): Full attribute name
+                ex: "rcsb_id", "rcsb_entity_source_organism.scientific_name"
 
+        Returns:
+            Union[str, None]: Return search service if there's a match.
+                structure search: "text"
+                chemical search: "chem_text"
+                both: ["text", "chem_text"] (raises error later)
+        """
+        split_attr = attribute.split(".")
+        ptr = self  # dictionary of attributes
+        for level in split_attr:
+            if level not in ptr:
+                warnings.warn(f"Attribute path segment '{level}' (for input '{attribute}') not found in schema.", UserWarning)
+                return None
+            ptr = ptr[level]
+        if "attribute" in ptr.__dict__ and getattr(ptr, "attribute") == attribute:  # must be .__dict__ so both SchemaGroup and Attr are compared as dictionaries
+            return getattr(ptr, "type")
+        warnings.warn(f"Incomplete attribute path '{attribute}' - must specify fully-qualified path to leaf attribute node.", UserWarning)
+        return None
 
-rcsb_attributes: SchemaGroup
-"""Object with all known RCSB PDB attributes.
+    # Below methods are for making SchemaGroup behave as a Dict (be able to access through keys, etc).
+    # This is used for automatically determining search service based on attribute name.
 
-This is provided to ease autocompletion as compared to creating Attr objects from
-strings. For example,
-::
+    def __getitem__(self, key):
+        """Allow dictionary-like access to members by key."""
+        return self._members[key]
 
-    rcsb_attributes.rcsb_nonpolymer_instance_feature_summary.chem_id
+    def __setitem__(self, key, value):
+        """Set a member in the schema like a dictionary."""
+        self._members[key] = value
 
-is equivalent to
-::
+    def __delitem__(self, key):
+        """Delete a member from the schema like a dictionary."""
+        del self._members[key]
 
-    Attr('rcsb_nonpolymer_instance_feature_summary.chem_id')
+    def __contains__(self, key):
+        """Check if a member exists in the schema."""
+        return key in self._members
 
-All attributes in `rcsb_attributes` can be iterated over.
+    def keys(self):
+        return self._members.keys()
 
-    >>> [a for a in rcsb_attributes if "stoichiometry" in a.attribute]
-    [Attr(attribute='rcsb_struct_symmetry.stoichiometry')]
+    def values(self):
+        return self._members.values()
 
-Attributes matching a regular expression can also be filtered:
+    def items(self):
+        return self._members.items()
 
-    >>> list(rcsb_attributes.search('rcsb.*stoichiometry'))
-    [Attr(attribute='rcsb_struct_symmetry.stoichiometry')]a
+    def __str__(self):
+        return "\n".join(f"{key}: {value}" for key, value in self._members.items())
 
-"""
+    def __hash__(self):
+        """Make the object hashable using the hash of its members."""
+        return hash(frozenset(self._members.items()))
+
+
+class Schema:
+    def __init__(
+        self,
+        attr_type,
+        refetch=True,
+        use_fallback=True,
+        reload=True,
+        struct_attr_schema_url=STRUCTURE_ATTRIBUTE_SCHEMA_URL,
+        struct_attr_schema_file=STRUCTURE_ATTRIBUTE_SCHEMA_FILE,
+        chem_attr_schema_url=CHEMICAL_ATTRIBUTE_SCHEMA_URL,
+        chem_attr_schema_file=CHEMICAL_ATTRIBUTE_SCHEMA_FILE,
+    ):
+        """Initialize Schema object with all known RCSB PDB attributes.
+
+        This is provided to ease autocompletion as compared to creating Attr objects from
+        strings. For example,
+        ::
+
+            rcsb_attributes.rcsb_nonpolymer_instance_feature_summary.chem_id
 
+        is equivalent to
+        ::
 
-def __getattr__(name: str) -> Any:
-    # delay instantiating rcsb_attributes until it is needed
-    if name == "rcsb_attributes":
-        if "rcsb_attributes" not in globals():
-            globals()["rcsb_attributes"] = _make_schema(STRUCTURE_ATTRIBUTE_SCHEMA_URL, CHEMICAL_ATTRIBUTE_SCHEMA_URL)
-        return globals()["rcsb_attributes"]
-    raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+            Attr('rcsb_nonpolymer_instance_feature_summary.chem_id')
 
+        All attributes in `rcsb_attributes` can be iterated over.
 
-def __dir__() -> List[str]:
-    return sorted(__all__)
+            >>> [a for a in rcsb_attributes if "stoichiometry" in a.attribute]
+            [Attr(attribute='rcsb_struct_symmetry.stoichiometry')]
+
+        Attributes matching a regular expression can also be filtered:
+
+            >>> list(rcsb_attributes.search('rcsb.*stoichiometry'))
+            [Attr(attribute='rcsb_struct_symmetry.stoichiometry')]a
+        """
+        self.Attr = attr_type
+        if reload:
+            self.struct_schema = self._reload_schema(struct_attr_schema_url, struct_attr_schema_file, refetch, use_fallback)
+            self.chem_schema = self._reload_schema(chem_attr_schema_url, chem_attr_schema_file, refetch, use_fallback)
+        self.rcsb_attributes = self._make_schema_group()
+
+    def _reload_schema(self, schema_url: str, schema_file: str, refetch=True, use_fallback=True):
+        sD = {}
+        if refetch:
+            sD = self._fetch_schema(schema_url)
+        if not sD and use_fallback:
+            sD = self._load_json_schema(schema_file)
+        return sD
+
+    def _make_schema_group(self) -> SchemaGroup:
+        schemas = [(self.struct_schema, STRUCTURE_ATTRIBUTE_SEARCH_SERVICE, ""), (self.chem_schema, CHEMICAL_ATTRIBUTE_SEARCH_SERVICE, "")]
+        schema = self._make_group("", schemas)
+        assert isinstance(schema, SchemaGroup)  # for type checking
+        return schema
+
+    def _fetch_schema(self, url: str):
+        "Request the current schema from the web"
+        logging.info("Requesting %s", url)
+        response = requests.get(url, timeout=None)
+        if response.status_code == 200:
+            return response.json()
+        else:
+            logging.debug("HTTP response status code %r", response.status_code)
+            return None
 
+    def _load_json_schema(self, schema_file):
+        logging.info("Loading attribute schema from file")
+        latest = pkgutil.get_data(__package__, schema_file)
+        return json.loads(latest)
 
-__all__ = [  # noqa: F822
-    "STRUCTURE_ATTRIBUTE_SCHEMA_URL",
-    "SEARCH_SCHEMA_URL",
-    "CHEMICAL_ATTRIBUTE_SCHEMA_URL",
-    "rcsb_attributes",
-    "SchemaGroup",
-]
+    def _make_group(self, fullname: str, nodeL: List):
+        """Represent this node of the schema as a python object
+
+        Params:
+        - name: full dot-separated attribute name
+
+        Returns:
+        An Attr (Leaf nodes) or SchemaGroup (object nodes)
+        """
+        group = SchemaGroup(self.Attr)
+        for node, attrtype, desc in nodeL:
+            if "anyOf" in node:
+                children = {self._make_group(fullname, [(n, attrtype, n.get("description", node.get("description", desc)))]) for n in node["anyOf"]}
+                # Currently only deal with anyOf in leaf nodes
+                assert len(children) == 1, f"type of {fullname} couldn't be determined"
+                return next(iter(children))
+            if "oneOf" in node:
+                children = {self._make_group(fullname, [(n, attrtype, n.get("description", desc))]) for n in node["oneOf"]}
+                # Currently only deal with oneOf in leaf nodes
+                assert len(children) == 1, f"type of {fullname} couldn't be determined"
+                return next(iter(children))
+            if "allOf" in node:
+                children = {self._make_group(fullname, [(n, attrtype, n.get("description", desc))]) for n in node["allOf"]}
+                # Currently only deal with allOf in leaf nodes
+                assert len(children) == 1, f"type of {fullname} couldn't be determined"
+                return next(iter(children))
+            if node["type"] in ("string", "number", "integer", "date"):
+                # For nodes that occur in both schemas, list of both descriptions will be passed in through desc arg
+                if isinstance(desc, list):
+                    return self.Attr(fullname, attrtype, desc)
+                # For non-redundant nodes
+                return self.Attr(fullname, attrtype, node.get("description", desc))
+            elif node["type"] == "array":
+                # skip to items
+                return self._make_group(fullname, [(node["items"], attrtype, node.get("description", desc))])
+            elif node["type"] == "object":
+                for childname, childnode in node["properties"].items():
+                    fullchildname = f"{fullname}.{childname}" if fullname else childname
+                    # setattr(group, childname, childgroup)
+                    if childname in group:
+                        assert not isinstance(group[childname], dict)  # redundant name must not have nested attributes
+
+                        # Create attrtype and description lists with existing and current value.
+                        # List type triggers error if user doesn't specify service for redundant attribute.
+                        currentattr = getattr(group[childname], "type")
+                        attrlist = [currentattr, attrtype]
+
+                        currentdescript = getattr(group[childname], "description")
+                        descriptlist = [currentdescript, childnode.get("description", desc)]
+
+                        childgroup = self._make_group(fullchildname, [(childnode, attrlist, descriptlist)])
+                    else:
+                        childgroup = self._make_group(fullchildname, [(childnode, attrtype, childnode.get("description", desc))])
+                    # adding to SchemaGroup as a dict allows for determining search service by attribute name with O(1) lookup
+                    group[childname] = childgroup
+
+                    # adding to SchemaGroup as an attribute allows for tab-completion for rcsb_attributes/attrs
+                    setattr(group, childname, childgroup)
+            else:
+                raise TypeError(f"Unrecognized node type {node['type']!r} of {fullname}")
+        return group
+
+    def _set_leaves(self, d: dict) -> dict:
+        """Converts Attr objects to dictionary format."""
+        for leaf in d:
+            if isinstance(d[leaf], self.Attr):
+                d[leaf] = d[leaf].__dict__
+            else:
+                d[leaf] = self._set_leaves(d[leaf])
+        return d
diff --git a/rcsbsearchapi/search.py b/rcsbsearchapi/search.py
index 053772f..527f791 100644
--- a/rcsbsearchapi/search.py
+++ b/rcsbsearchapi/search.py
@@ -1,6 +1,7 @@
 """Interact with the [RCSB PDB Search API](https://search.rcsb.org/#search-api).
 """
 
+from __future__ import annotations
 import functools
 import json
 import logging
@@ -31,37 +32,73 @@
 from .const import STRUCTURE_ATTRIBUTE_SEARCH_SERVICE, REQUESTS_PER_SECOND, FULL_TEXT_SEARCH_SERVICE, SEQUENCE_SEARCH_SERVICE, SEQUENCE_SEARCH_MIN_NUM_OF_RESIDUES
 from .const import RCSB_SEARCH_API_QUERY_URL, SEQMOTIF_SEARCH_SERVICE, SEQMOTIF_SEARCH_MIN_CHARACTERS, UPLOAD_URL, RETURN_UP_URL, STRUCT_SIM_SEARCH_SERVICE
 from .const import STRUCTMOTIF_SEARCH_SERVICE, STRUCT_MOTIF_MIN_RESIDUES, STRUCT_MOTIF_MAX_RESIDUES, CHEM_SIM_SEARCH_SERVICE
+from .schema import Schema
 
 if sys.version_info > (3, 8):
     from typing import Literal
 else:
     from typing_extensions import Literal
+
 # tqdm is optional
 # Allowed return types for searches. https://search.rcsb.org/#return-type
-ReturnType = Literal[
-    "entry", "assembly", "polymer_entity", "non_polymer_entity", "polymer_instance",
-    "mol_definition"
-]
+ReturnType = Literal["entry", "assembly", "polymer_entity", "non_polymer_entity", "polymer_instance", "mol_definition"]
 ReturnContentType = Literal["experimental", "computational"]  # results_content_type parameter list values
 SequenceType = Literal["dna", "rna", "protein"]  # possible sequence types for sequence searching
 SeqMode = Literal["simple", "prosite", "regex"]  # possible sequence motif formats
 StructEntryType = Literal["entry_id", "file_url", "file_upload"]  # possible entry types for structure similarity search
 StructSimInputType = Literal["assembly_id", "chain_id"]  # Possible ID choices for structure similarity search
-StructSimSearchSpace = Literal["polymer_entity_instance", "assembly"]  # target search spaces for structure similarity search
-StructSimOperator = Literal["strict_shape_match", "relaxed_shape_match"]  # possible operators for structure similarity search
-StructMotifExchanges = Literal["ALA", "CYS", "ASP", "GLU", "PHE", "GLY",
-                               "HIS", "ILE", "LYS", "LEU", "MET", "ASN",
-                               "PYL", "PRO", "GLN", "ARG", "SER", "THR",
-                               "SEC", "VAL", "TRP", "TYR", "DA", "DC",
-                               "DG", "DI", "DT", "DU", "A", "C", "G",
-                               "I", "U", "UNK", "N"]
+StructSimSearchSpace = Literal["polymer_entity_instance", "assembly"]  # target search spaces for structure similarity searchf
+StructSimOperator = Literal["strict_shape_match", "relaxed_shape_match"]  # possible operators for structure similarity searchf
+StructMotifExchanges = Literal[
+    "ALA",
+    "CYS",
+    "ASP",
+    "GLU",
+    "PHE",
+    "GLY",
+    "HIS",
+    "ILE",
+    "LYS",
+    "LEU",
+    "MET",
+    "ASN",
+    "PYL",
+    "PRO",
+    "GLN",
+    "ARG",
+    "SER",
+    "THR",
+    "SEC",
+    "VAL",
+    "TRP",
+    "TYR",
+    "DA",
+    "DC",
+    "DG",
+    "DI",
+    "DT",
+    "DU",
+    "A",
+    "C",
+    "G",
+    "I",
+    "U",
+    "UNK",
+    "N",
+]
 StructMotifTolerance = Literal[0, 1, 2, 3]
 StructMotifAtomPairing = Literal["ALL", "BACKBONE", "SIDE_CHAIN", "PSEUDO_ATOMS"]
 StructMotifPruning = Literal["NONE", "KRUSKAL"]
 SubsetDescriptorType = Literal["InChI", "SMILES"]  # possible subset matching or descriptor types parameters for chemical similarity search
 ChemSimType = Literal["formula", "descriptor"]  # possible query types for chemical similarity search
-ChemSimMatchType = Literal["graph-relaxed-stereo", "graph-relaxed", "fingerprint-similarity",  # possible match types for descriptor query type (Chemical similarity search)
-                           "sub-struct-graph-relaxed-stereo", "sub-struct-graph-relaxed", "graph-exact"]
+ChemSimMatchType = Literal[
+    "graph-relaxed-stereo",
+    "graph-relaxed",
+    "fingerprint-similarity",  # possible match types for descriptor query type (Chemical similarity search)
+    "sub-struct-graph-relaxed-stereo",
+    "sub-struct-graph-relaxed",
+    "graph-exact",
+]
 TAndOr = Literal["and", "or"]
 VerbosityLevel = Literal["compact", "minimal", "verbose"]
 AggregationType = Literal["terms", "histogram", "date_histogram", "range", "date_range", "cardinality"]
@@ -89,8 +126,8 @@ def fileUpload(filepath: str, fmt: str = "cif") -> str:
     """Take a file given by a filepath, and return the
     corresponding URL to use in a structure search. This URL
     should then be passed through as part of the value parameter,
-    along with the format of the file. """
-    x = open(filepath, mode='rb')
+    along with the format of the file."""
+    x = open(filepath, mode="rb")
     res = requests.post(UPLOAD_URL, files={"file": x, "format": fmt}, timeout=None)
     try:
         spec = res.json()["key"]
@@ -173,22 +210,14 @@ def __xor__(self, other: "Query") -> "Query":
         return (self & ~other) | (~self & other)
 
     def exec(
-        self,
-        return_type: ReturnType = "entry",
-        rows: int = 10000,
-        return_content_type: List[ReturnContentType] = ["experimental"],
-        results_verbosity: VerbosityLevel = "compact"
+        self, return_type: ReturnType = "entry", rows: int = 10000, return_content_type: List[ReturnContentType] = ["experimental"], results_verbosity: VerbosityLevel = "compact"
     ) -> "Session":
         # pylint: disable=dangerous-default-value
         """Evaluate this query and return an iterator of all result IDs"""
         return Session(self, return_type, rows, return_content_type, results_verbosity)
 
     def __call__(
-        self,
-        return_type: ReturnType = "entry",
-        rows: int = 10000,
-        return_content_type: List[ReturnContentType] = ["experimental"],
-        results_verbosity: VerbosityLevel = "compact"
+        self, return_type: ReturnType = "entry", rows: int = 10000, return_content_type: List[ReturnContentType] = ["experimental"], results_verbosity: VerbosityLevel = "compact"
     ) -> "Session":
         # pylint: disable=dangerous-default-value
         """Evaluate this query and return an iterator of all result IDs"""
@@ -215,9 +244,7 @@ def and_(self, other: "Query") -> "Query":
     def and_(self, other: Union[str, "Attr"]) -> "PartialQuery":
         ...
 
-    def and_(
-        self, other: Union[str, "Query", "Attr"], qtype=STRUCTURE_ATTRIBUTE_SEARCH_SERVICE
-    ) -> Union["Query", "PartialQuery"]:
+    def and_(self, other: Union[str, "Query", "Attr"], qtype=STRUCTURE_ATTRIBUTE_SEARCH_SERVICE) -> Union["Query", "PartialQuery"]:
         """Extend this query with an additional attribute via an AND"""
         if isinstance(other, Query):
             return self & other
@@ -239,7 +266,7 @@ def or_(self, other: Union[str, "Attr"]) -> "PartialQuery":
     def or_(self, other: Union[str, "Query", "Attr"], qtype=STRUCTURE_ATTRIBUTE_SEARCH_SERVICE) -> Union["Query", "PartialQuery"]:
         """Extend this query with an additional attribute via an OR"""
         if isinstance(other, Query):
-            return self & other
+            return self | other
         elif isinstance(other, Attr):
             return PartialQuery(self, "or", other)
         elif isinstance(other, str):
@@ -263,7 +290,8 @@ class Terminal(Query):
         >>> Terminal("full_text", {"value": "protease"})
         >>> Terminal("text", {"attribute": "rcsb_id", "operator": "in", "negation": False, "value": ["5T89, "1TIM"]})
     """
-    service: str
+
+    service: Union[List, str]
     params: Dict[str, Any]
     node_id: int = 0
 
@@ -277,12 +305,7 @@ def to_dict(self):
 
     def __invert__(self):
         if isinstance(self, AttributeQuery):
-            return AttributeQuery(
-                attribute=self.params.get("attribute"),
-                operator=self.params.get("operator"),
-                negation=not self.params.get("negation"),
-                value=self.params.get("value")
-            )
+            return AttributeQuery(attribute=self.params.get("attribute"), operator=self.params.get("operator"), negation=not self.params.get("negation"), value=self.params.get("value"))
         else:
             raise TypeError("Negation is not supported by type " + str(type(self)))  # Attribute Queries are the only query type to support inversion.
 
@@ -305,6 +328,282 @@ def _assign_ids(self, node_id=0) -> Tuple[Query, int]:
     #     return f"Terminal(service={self.service!r}, params={self.params!r})"
 
 
+@dataclass(frozen=True)
+class Attr:
+    """A search attribute, e.g. "rcsb_entry_container_identifiers.entry_id"
+
+    Terminals can be constructed from Attr objects using either a functional syntax,
+    which mirrors the API operators, or with python operators.
+
+    +--------------------+---------------------+
+    | Fluent Function    | Operator            |
+    +====================+=====================+
+    | exact_match        | attr == str         |
+    +--------------------+---------------------+
+    | contains_words     |                     |
+    +--------------------+---------------------+
+    | contains_phrase    |                     |
+    +--------------------+---------------------+
+    | greater            | attr > date,number  |
+    +--------------------+---------------------+
+    | less               | attr < date,number  |
+    +--------------------+---------------------+
+    | greater_or_equal   | attr >= date,number |
+    +--------------------+---------------------+
+    | less_or_equal      | attr <= date,number |
+    +--------------------+---------------------+
+    | equals             | attr == date,number |
+    +--------------------+---------------------+
+    | range              | dict (keys below)*  |
+    +--------------------+---------------------+
+    | exists             |                     |
+    +--------------------+---------------------+
+    | in\\_              |                     |
+    +--------------------+---------------------+
+
+    Previously, __bool__ was overloaded to run the exists function, but __bool__ can't be overloaded to return non-boolean value.
+    Method overloading bool was deleted.
+
+    Rather than their normal bool return values, operators return Terminals.
+
+    Pre-instantiated attributes are available from the
+    :py:data:`rcsbsearchapi.rcsb_attributes` object. These are generally easier to use
+    than constructing Attr objects by hand. A complete list of valid attributes is
+    available in the `schema <https://search.rcsb.org/rcsbsearch/v2/metadata/schema>`_.
+
+    * The `range` dictionary requires the following keys:
+     * "from" -> int
+     * "to" -> int
+     * "include_lower" -> bool
+     * "include_upper" -> bool
+    """
+
+    attribute: str
+    type: Optional[Union[List, str]] = None
+    description: Optional[Union[str, List[str]]] = None
+
+    def exact_match(self, value: Union[str, "Value[str]"]) -> "AttributeQuery":
+        """Exact match with the value"""
+        if isinstance(value, Value):
+            value = value.value
+        return AttributeQuery(self.attribute, "exact_match", value, self.type)
+
+    def contains_words(self, value: Union[str, "Value[str]", List[str], "Value[List[str]]"]) -> "AttributeQuery":
+        """Match any word within the string.
+
+        Words are split at whitespace. All results which match any word are returned,
+        with results matching more words sorted first.
+        """
+        if isinstance(value, Value):
+            value = value.value
+        if isinstance(value, list):
+            value = " ".join(value)
+        return AttributeQuery(self.attribute, "contains_words", value, self.type)
+
+    def contains_phrase(self, value: Union[str, "Value[str]"]) -> "AttributeQuery":
+        """Match an exact phrase"""
+        if isinstance(value, Value):
+            value = value.value
+        return AttributeQuery(self.attribute, "contains_phrase", value, self.type)
+
+    def greater(self, value: TNumberLike) -> "AttributeQuery":
+        """Attribute > `value`"""
+        if isinstance(value, Value):
+            value = value.value
+        return AttributeQuery(self.attribute, "greater", value, self.type)
+
+    def less(self, value: TNumberLike) -> "AttributeQuery":
+        """Attribute < `value`"""
+        if isinstance(value, Value):
+            value = value.value
+        return AttributeQuery(self.attribute, "less", value, self.type)
+
+    def greater_or_equal(self, value: TNumberLike) -> "AttributeQuery":
+        """Attribute >= `value`"""
+        if isinstance(value, Value):
+            value = value.value
+        return AttributeQuery(self.attribute, "greater_or_equal", value, self.type)
+
+    def less_or_equal(self, value: TNumberLike) -> "AttributeQuery":
+        """Attribute <= `value`"""
+        if isinstance(value, Value):
+            value = value.value
+        return AttributeQuery(self.attribute, "less_or_equal", value, self.type)
+
+    def equals(self, value: TNumberLike) -> "AttributeQuery":
+        """Attribute == `value`"""
+        if isinstance(value, Value):
+            value = value.value
+        return AttributeQuery(self.attribute, "equals", value, self.type)
+
+    def range(self, value: Dict[str, Any]) -> "AttributeQuery":
+        """Attribute is within the specified half-open range
+
+        Args:
+            value: lower and upper bounds `[a, b)`
+        """
+        if isinstance(value, Value):
+            value = value.value
+        return AttributeQuery(self.attribute, "range", value, self.type)
+
+    def exists(self) -> AttributeQuery:
+        """Attribute is defined for the structure"""
+        return AttributeQuery(self.attribute, operator="exists")
+
+    def in_(
+        self,
+        value: Union[
+            List[str],
+            List[int],
+            List[float],
+            List[date],
+            Tuple[str, ...],
+            Tuple[int, ...],
+            Tuple[float, ...],
+            Tuple[date, ...],
+            "Value[List[str]]",
+            "Value[List[int]]",
+            "Value[List[float]]",
+            "Value[List[date]]",
+            "Value[Tuple[str, ...]]",
+            "Value[Tuple[int, ...]]",
+            "Value[Tuple[float, ...]]",
+            "Value[Tuple[date, ...]]",
+        ],
+    ) -> "AttributeQuery":
+        """Attribute is contained in the list of values"""
+        if isinstance(value, Value):
+            value = value.value
+        return AttributeQuery(self.attribute, operator="in", value=value)
+
+    # Need ignore[override] because typeshed restricts __eq__ return value
+    # https://github.com/python/mypy/issues/2783
+    @overload  # type: ignore[override]
+    def __eq__(self, value: "Attr") -> bool:
+        ...
+
+    @overload  # type: ignore[override]
+    def __eq__(
+        self,
+        value: Union[
+            str,
+            int,
+            float,
+            date,
+            "Value[str]",
+            "Value[int]",
+            "Value[float]",
+            "Value[date]",
+        ],
+    ) -> Terminal:
+        ...
+
+    def __eq__(
+        self,
+        value: Union[
+            "Attr",
+            str,
+            int,
+            float,
+            date,
+            "Value[str]",
+            "Value[int]",
+            "Value[float]",
+            "Value[date]",
+        ],
+    ) -> Union[Terminal, bool]:  # type: ignore[override]
+        if isinstance(value, Attr):
+            return self.attribute == value.attribute
+        if isinstance(value, Value):
+            value = value.value
+        if isinstance(value, str):
+            return self.exact_match(value)
+        elif isinstance(value, date) or isinstance(value, float) or isinstance(value, int):
+            return self.equals(value)
+        else:
+            return NotImplemented
+
+    @overload  # type: ignore[override]
+    def __ne__(self, value: "Attr") -> bool:
+        ...
+
+    @overload  # type: ignore[override]
+    def __ne__(
+        self,
+        value: Union[
+            str,
+            int,
+            float,
+            date,
+            "Value[str]",
+            "Value[int]",
+            "Value[float]",
+            "Value[date]",
+        ],
+    ) -> Terminal:
+        ...
+
+    def __ne__(
+        self,
+        value: Union[
+            "Attr",
+            str,
+            int,
+            float,
+            date,
+            "Value[str]",
+            "Value[int]",
+            "Value[float]",
+            "Value[date]",
+        ],
+    ) -> Union[Terminal, bool]:  # type: ignore[override]
+        if isinstance(value, Attr):
+            return self.attribute != value.attribute
+        if isinstance(value, Value):
+            value = value.value
+        return ~(self == value)
+
+    def __lt__(self, value: TNumberLike) -> Terminal:
+        if isinstance(value, Value):
+            value = value.value
+        return self.less(value)
+
+    def __le__(self, value: TNumberLike) -> Terminal:
+        if isinstance(value, Value):
+            value = value.value
+        return self.less_or_equal(value)
+
+    def __gt__(self, value: TNumberLike) -> Terminal:
+        if isinstance(value, Value):
+            value = value.value
+        return self.greater(value)
+
+    def __ge__(self, value: TNumberLike) -> Terminal:
+        if isinstance(value, Value):
+            value = value.value
+        return self.greater_or_equal(value)
+
+    def __contains__(self, value: Union[str, List[str], "Value[str]", "Value[List[str]]"]) -> Terminal:
+        """Maps to contains_words or contains_phrase depending on the value passed.
+
+        * `"value" in attr` maps to `attr.contains_phrase("value")` for simple values.
+        * `["value"] in attr` maps to `attr.contains_words(["value"])` for lists and
+          tuples.
+        """
+        if isinstance(value, Value):
+            value = value.value
+        if isinstance(value, list):
+            if len(value) == 0 or isinstance(value[0], str):
+                return self.contains_words(value)
+            else:
+                return NotImplemented
+        else:
+            return self.contains_phrase(value)
+
+
+SCHEMA = Schema(Attr)
+
+
 class AttributeQuery(Terminal):
     """Special case of a Terminal for Structure and Chemical Attribute Searches
 
@@ -312,7 +611,6 @@ class AttributeQuery(Terminal):
 
     Examples:
         >>> AttributeQuery("exptl.method", "exact_match", "X-RAY DIFFRACTION")
-        >>> AttributeQuery(value="tubulin")
         >>> AttributeQuery("rcsb_entry_container_identifiers.entry_id", operator="in", value=["4HHB", "2GS2"])
 
     A full list of attributes is available in the
@@ -322,31 +620,41 @@ class AttributeQuery(Terminal):
     The :py:class:`Attr` class provides a more pythonic way of constructing AttributeQueries.
     """
 
-    def __init__(self, attribute: Optional[str] = None,
-                 operator: Optional[str] = None,
-                 value: Optional[TValue] = None,
-                 service: str = STRUCTURE_ATTRIBUTE_SEARCH_SERVICE,
-                 negation: Optional[bool] = False
-                 ):
+    def __init__(
+        self,
+        attribute: str = None,
+        operator: str = None,
+        value: Optional[TValue] = None,
+        service: Optional[str] = None,
+        negation: Optional[bool] = False,
+    ):
         """Search for the string value given possible attribute or operator
         Also can specify service and negation
 
         Args:
-            attribute: specify attribute for search (i.e struct.title, exptl.method, rcsb_id)
-            operator: specify operation to be done for search (i.e "contains_phrase", "exact_match")
-            value: text query
-            service: specify what search service (i.e "text", "text_chem")
-            negation: logical not
+            attribute: full attribute name (i.e struct.title, exptl.method, rcsb_id)
+            operator: operation for query (i.e "contains_phrase", "exact_match")
+            value: search term(s)
+            service: specify structure or chemical search service (i.e "text", "text_chem")
+            negation: indicates if the operator is negated
         """
+        paramsD = {"attribute": attribute, "operator": operator, "negation": negation}
+
         if value is not None:
-            super().__init__(params={"attribute": attribute,
-                                     "operator": operator,
-                                     "negation": negation,
-                                     "value": value}, service=service)
-        else:
-            super().__init__(params={"attribute": attribute,
-                                     "operator": operator,
-                                     "negation": negation}, service=service)
+            paramsD.update({"value": value})
+        if not service:
+            service = SCHEMA.rcsb_attributes.get_attribute_type(attribute)
+
+        if isinstance(service, list):
+            error_msg = ""
+            for serv in service:
+                error_msg += f'  AttributeQuery(attribute="{attribute}", operator="{operator}", value="{value}", service="{serv}")\n'
+            raise ValueError(
+                f'"{attribute}" is in both structure and chemical attributes. Construct an AttributeQuery and specify search service.\n'
+                + f"{error_msg}"
+            )
+        assert isinstance(service, str)
+        super().__init__(params=paramsD, service=service)
 
 
 class TextQuery(Terminal):
@@ -364,83 +672,98 @@ def __init__(self, value: str):
 class SequenceQuery(Terminal):
     """Special case of a terminal for protein, DNA, or RNA sequence queries"""
 
-    def __init__(self, value: str,
-                 evalue_cutoff: Optional[float] = 0.1,
-                 identity_cutoff: Optional[float] = 0,
-                 sequence_type: Optional[SequenceType] = "protein"
-                 ):
-        """The string value is a target sequence that is searched
+    def __init__(self, value: str, evalue_cutoff: Optional[float] = 0.1, identity_cutoff: Optional[float] = 0, sequence_type: Optional[SequenceType] = "protein"):
+        """
         Args:
-            value: sequence query
+            value (str): protein or nucleotide sequence
+            evalue_cutoff (Optional[float], optional): upper cutoff for E-value (lower is more significant).
+                Defaults to 0.1.
+            identity_cutoff (Optional[float], optional): lower cutoff for percent sequence match (0-1). Defaults to 0.
+            sequence_type (Optional[SequenceType], optional): type of biological sequence ("protein", "dna", "rna").
+                Defaults to "protein".
         """
         if len(value) < SEQUENCE_SEARCH_MIN_NUM_OF_RESIDUES:  # (placeholder for now) look into deriving constraints from API Schema programatically
             raise ValueError("The sequence must contain at least 25 residues")
         if identity_cutoff < 0.0 or identity_cutoff > 1.0:
             raise ValueError("Identity cutoff should be between 0 and 1 (inclusive)")
         else:
-            super().__init__(service=SEQUENCE_SEARCH_SERVICE, params={"evalue_cutoff": evalue_cutoff,
-                                                                      "identity_cutoff": identity_cutoff,
-                                                                      "sequence_type": sequence_type,
-                                                                      "value": value
-                                                                      })
+            super().__init__(
+                service=SEQUENCE_SEARCH_SERVICE, params={"evalue_cutoff": evalue_cutoff, "identity_cutoff": identity_cutoff, "sequence_type": sequence_type, "value": value}
+            )
 
 
 class SeqMotifQuery(Terminal):
     """Special case of a terminal for protein, DNA, or RNA sequence motif queries"""
 
     def __init__(self, value: str, pattern_type: Optional[SeqMode] = "simple", sequence_type: Optional[SequenceType] = "protein"):
+        """
+        Args:
+            value (str): motif to search
+            pattern_type (Optional[SeqMode], optional): motif syntax ("simple", "prosite", "regex"). Defaults to "simple".
+            sequence_type (Optional[SequenceType], optional): type of biological sequence ("protein", "dna", "rna"). Defaults to "protein".
+        """
         if len(value) < SEQMOTIF_SEARCH_MIN_CHARACTERS:
             raise ValueError("The sequence motif must contain at least 2 characters")
         else:
-            super().__init__(service=SEQMOTIF_SEARCH_SERVICE, params={"value": value,
-                                                                      "pattern_type": pattern_type,
-                                                                      "sequence_type": sequence_type})
+            super().__init__(service=SEQMOTIF_SEARCH_SERVICE, params={"value": value, "pattern_type": pattern_type, "sequence_type": sequence_type})
 
 
 class StructSimilarityQuery(Terminal):
     """Special case of a terminal for structure similarity queries"""
 
-    def __init__(self, structure_search_type: StructEntryType = "entry_id",
-                 entry_id: Optional[str] = None,
-                 file_url: Optional[str] = None,
-                 file_path: Optional[str] = None,
-                 structure_input_type: Optional[StructSimInputType] = "assembly_id",
-                 assembly_id: Optional[str] = "1",
-                 chain_id: Optional[str] = None,
-                 operator: StructSimOperator = "strict_shape_match",
-                 target_search_space: StructSimSearchSpace = "assembly",
-                 file_format: Optional[str] = None
-                 ):
-
-        parameters = {"operator": operator,
-                      "target_search_space": target_search_space}
+    def __init__(
+        self,
+        structure_search_type: StructEntryType = "entry_id",
+        entry_id: Optional[str] = None,
+        file_url: Optional[str] = None,
+        file_format: Optional[str] = None,
+        file_path: Optional[str] = None,
+        structure_input_type: Optional[StructSimInputType] = "assembly_id",
+        assembly_id: Optional[str] = "1",
+        chain_id: Optional[str] = None,
+        operator: StructSimOperator = "strict_shape_match",
+        target_search_space: StructSimSearchSpace = "assembly",
+    ):
+        """
+        Args:
+            structure_search_type (StructEntryType, optional): how to find given structure ("entry_id", "file_url", "file_path"). Defaults to "entry_id".
+            entry_id (Optional[str], optional): if "entry_id" specified, PDB ID or CSM ID. Defaults to None.
+            file_url (Optional[str], optional): if "file_url" specified, url to file . Defaults to None.
+            file_path (Optional[str], optional): if "file_path" specified, path to file. Defaults to None.
+            file_format (Optional[str], optional): if "file_url" specified, type of file linked to (ex: "cif"). Defaults to None.
+            structure_input_type (Optional[StructSimInputType], optional): type of the given structure . Defaults to "assembly_id".
+            assembly_id (Optional[str], optional): if input_type is "assembly_id", the assembly id number. Defaults to "1".
+            chain_id (Optional[str], optional): if input_type is "chain_id", the chain id letter. Defaults to None.
+            operator (StructSimOperator, optional): search mode ("strict_shape_match" or "relaxed_shape_match"). Defaults to "strict_shape_match".
+            target_search_space (StructSimSearchSpace, optional): target objects against which the query will be compared for shape similarity. Defaults to "assembly".
+        """
+
+        parameters = {"operator": operator, "target_search_space": target_search_space}
 
         if structure_search_type == "entry_id":
             if structure_input_type == "assembly_id":
-                parameters["value"] = {"entry_id": entry_id,
-                                       "assembly_id": assembly_id}
+                parameters["value"] = {"entry_id": entry_id, "assembly_id": assembly_id}
             elif structure_input_type == "chain_id":
-                parameters["value"] = {"entry_id": entry_id,
-                                       "asym_id": chain_id}
+                parameters["value"] = {"entry_id": entry_id, "asym_id": chain_id}
 
         elif structure_search_type == "file_url":
-            parameters["value"] = {"url": file_url,
-                                   "format": file_format}
+            parameters["value"] = {"url": file_url, "format": file_format}
 
         elif structure_search_type == "file_upload":
-            parameters["value"] = {"url": fileUpload(file_path, file_format),
-                                   "format": "bcif"}
+            parameters["value"] = {"url": fileUpload(file_path, file_format), "format": "bcif"}
 
         super().__init__(service=STRUCT_SIM_SEARCH_SERVICE, params=parameters)
 
 
-class StructureMotifResidue():
-    """This class is for defining residues. For use with the Structure Motif Search. """
+class StructureMotifResidue:
+    """This class is for defining residues. For use with the Structure Motif Search."""
+
     def __init__(
-        self, chain_id: Optional[str] = None,
+        self,
+        chain_id: Optional[str] = None,
         struct_oper_id: Optional[str] = None,
         label_seq_id: Optional[str] = None,
-        exchanges: Optional[list] = None  # List of StructMotifExchanges objects
+        exchanges: Optional[list] = None,  # List of StructMotifExchanges objects
     ):
         assert chain_id, "You must provide a chain_id."
         assert struct_oper_id, "You must provide a struct_oper_id."
@@ -456,9 +779,7 @@ def __init__(
             self.exchanges = None
 
     def to_dict(self):
-        return {"label_asym_id": self.label_asym,
-                "struct_oper_id": self.struct_oper_id,
-                "label_seq_id": self.label_seq_id}
+        return {"label_asym_id": self.label_asym, "struct_oper_id": self.struct_oper_id, "label_seq_id": self.label_seq_id}
 
 
 class StructMotifQuery(Terminal):
@@ -470,7 +791,7 @@ class StructMotifQuery(Terminal):
 
     As is standard with Structure Motif Queries, you must include a list of residues.
 
-    Positional arguments STRONGLY discouraged. """
+    Positional arguments STRONGLY discouraged."""
 
     def __init__(
         self,
@@ -488,8 +809,27 @@ def __init__(
         motif_pruning_strategy: StructMotifPruning = "KRUSKAL",
         allowed_structures: Optional[list] = None,  # List of strings
         excluded_structures: Optional[list] = None,  # List of strings
-        limit: Optional[int] = None
+        limit: Optional[int] = None,
     ):
+        """
+        Args:
+            structure_search_type (StructEntryType, optional): how to find given structure ("entry_id", "url", "file_path"). Defaults to "entry_id".
+            backbone_distance_tolerance (StructMotifTolerance, optional): tolerance for distance between Cα atoms (in Å). Defaults to 1.
+            side_chain_distance_tolerance (StructMotifTolerance, optional): tolerance for distance between Cβ atoms (in Å). Defaults to 1.
+            angle_tolerance (StructMotifTolerance, optional): angle between CαCβ vectors (in multiples of 20 degrees). Defaults to 1.
+            entry_id (Optional[str], optional): if "entry_id" specified, PDB ID or CSM ID . Defaults to None.
+            url (Optional[str], optional): if "file_url" specified, url to file. Defaults to None.
+            file_path (Optional[str], optional): if "file_path" specified, path to file. Defaults to None.
+            file_extension (Optional[str], optional): if "file_url" specified, type of file linked to (ex: "cif"). Defaults to None.
+            residue_ids (Optional[list], optional): list of StructureMotifResidue objects . Defaults to None.
+            rmsd_cutoff (int, optional): upper cutoff for root-mean-square deviation (RMSD) score. Defaults to 2.
+            atom_pairing_scheme (StructMotifAtomPairing, optional): Which atoms to consider to compute RMSD scores and transformations. Defaults to "SIDE_CHAIN".
+            motif_pruning_strategy (StructMotifPruning, optional): specifies how query motifs are pruned (i.e. simplified). Defaults to "KRUSKAL".
+            allowed_structures (Optional[list], optional): list of allowed residues specified by strings (ex: ["HIS", "LYS"]). Defaults to None.
+            excluded_structures (Optional[list], optional): if the list of structure identifiers is specified, the search will exclude those structures from the search space.
+                Defaults to None.
+            limit (Optional[int], optional): stop after accepting this many hits. Defaults to None.
+        """
         # we will construct value, and then pass it through. That's like 95% of this lol
         if not residue_ids:
             raise ValueError("You must include residues in a Structure Motif Query")
@@ -517,416 +857,148 @@ def __init__(
         for x in residue_ids:
             residue_id_dicts.append(x.to_dict())
             if x.exchanges:
-                exchanges.append({"residue_id": x.to_dict(),
-                                  "allowed": x.exchanges})
+                exchanges.append({"residue_id": x.to_dict(), "allowed": x.exchanges})
                 total_res += len(x.exchanges)
                 assert total_res <= 16, "No more than 16 allowed exchanges total per query, regardless of residue count."
         value["residue_ids"] = residue_id_dicts
 
-        # assemble params. This is done differently from before because so many of these are optional here,
-        # and I'm not aware of a method to make inclusions be skipped if a value is NONE when
-        # declaring values on instantiation.
-
-        params = {"value": value,
-                  "backbone_distance_tolerance": backbone_distance_tolerance,
-                  "side_chain_distance_tolerance": side_chain_distance_tolerance,
-                  "angle_tolerance": angle_tolerance,
-                  "rmsd_cutoff": rmsd_cutoff,
-                  "atom_pairing_scheme": atom_pairing_scheme,
-                  "motif_pruning_strategy": motif_pruning_strategy}
-        if allowed_structures:
-            params["allowed_structures"] = allowed_structures
-        if excluded_structures:
-            params["excluded_structures"] = excluded_structures
-        if exchanges:
-            params["exchanges"] = exchanges
-        if limit:
-            params["limit"] = limit
-
-        # now call super
-
-        super().__init__(service=STRUCTMOTIF_SEARCH_SERVICE, params=params)
-
-
-class ChemSimilarityQuery(Terminal):
-    """Special case of Terminal for chemical similarity search queries"""
-    def __init__(self, value: Optional[str] = None,
-                 query_type: ChemSimType = "formula",
-                 match_subset: Optional[bool] = False,
-                 descriptor_type: Optional[SubsetDescriptorType] = None,
-                 match_type: Optional[ChemSimMatchType] = None):
-        """Guide for "descriptor_type" options:
-        +-------------------------------------------+-----------------------------------+
-        | Match Type                                | descriptor_type                   |
-        +===========================================+===================================+
-          Similar Ligands (Stereospecific)          | "graph-relaxed-stereo"
-          Similar Ligands (including Stereoisomers) | "graph-relaxed"
-          Similar Ligands (Quick screen)            | "fingerprint-similarity"
-          Substructure (Stereospecific)             | "sub-struct-graph-relaxed-stereo"
-          Substructure (including Stereoisomers)    | "sub-struct-graph-relaxed"
-          Exact match                               | "graph-exact"
-        +-------------------------------------------+-----------------------------------+
-        """
-
-        parameters = {"value": value,
-                      "type": query_type}
-
-        if query_type == "formula":
-            parameters["match_subset"] = match_subset
-
-        elif query_type == "descriptor":
-            parameters["descriptor_type"] = descriptor_type
-            parameters["match_type"] = match_type
-
-        super().__init__(service=CHEM_SIM_SEARCH_SERVICE, params=parameters)
-
-
-@dataclass(frozen=True)
-class Group(Query):
-    """AND and OR combinations of queries"""
-
-    operator: TAndOr
-    nodes: Iterable[Query] = ()
-
-    def to_dict(self):
-        return dict(
-            type="group",
-            logical_operator=self.operator,
-            nodes=[node.to_dict() for node in self.nodes],
-        )
-
-    def __invert__(self):
-        if self.operator == "and":
-            return Group("or", [~node for node in self.nodes])
-
-    def __and__(self, other: Query) -> Query:
-        # Combine nodes if possible
-        if self.operator == "and":
-            if isinstance(other, Group):
-                if other.operator == "and":
-                    return Group("and", (*self.nodes, *other.nodes))
-            elif isinstance(other, Query):
-                return Group("and", (*self.nodes, other))
-            else:
-                return NotImplemented
-
-        return super().__and__(other)
-
-    def __or__(self, other: Query) -> Query:
-        # Combine nodes if possible
-        if self.operator == "or":
-            if isinstance(other, Group):
-                if other.operator == "or":
-                    return Group("or", (*self.nodes, *other.nodes))
-            elif isinstance(other, Query):
-                return Group("or", (*self.nodes, other))
-            else:
-                return NotImplemented
-
-        return super().__or__(other)
-
-    def _assign_ids(self, node_id=0) -> Tuple[Query, int]:
-        nodes = []
-        changed = False
-        for node in self.nodes:
-            assigned = node._assign_ids(node_id)
-            nodes.append(assigned[0])
-            node_id = assigned[1]
-            # Track whether any nodes were modified
-            changed = changed or assigned[0] is node
-        if changed:
-            return (Group(self.operator, nodes), node_id)
-        else:
-            return (self, node_id)
-
-    def __str__(self):
-        """"""  # hide in documentation
-        if self.operator == "and":
-            return f"({' & '.join((str(n) for n in self.nodes))})"
-        elif self.operator == "or":
-            return f"({' | '.join((str(n) for n in self.nodes))})"
-        else:
-            raise ValueError("Illegal Operator")
-
-
-@dataclass(frozen=True)
-class Attr:
-    """A search attribute, e.g. "rcsb_entry_container_identifiers.entry_id"
-
-    Terminals can be constructed from Attr objects using either a functional syntax,
-    which mirrors the API operators, or with python operators.
-
-    +--------------------+---------------------+
-    | Fluent Function    | Operator            |
-    +====================+=====================+
-    | exact_match        | attr == str         |
-    +--------------------+---------------------+
-    | contains_words     |                     |
-    +--------------------+---------------------+
-    | contains_phrase    |                     |
-    +--------------------+---------------------+
-    | greater            | attr > date,number  |
-    +--------------------+---------------------+
-    | less               | attr < date,number  |
-    +--------------------+---------------------+
-    | greater_or_equal   | attr >= date,number |
-    +--------------------+---------------------+
-    | less_or_equal      | attr <= date,number |
-    +--------------------+---------------------+
-    | equals             | attr == date,number |
-    +--------------------+---------------------+
-    | range              | dict (keys below)*  |
-    +--------------------+---------------------+
-    | exists             | bool(attr)          |
-    +--------------------+---------------------+
-    | in\\_              |                     |
-    +--------------------+---------------------+
-
-    Rather than their normal bool return values, operators return Terminals.
-
-    Pre-instantiated attributes are available from the
-    :py:data:`rcsbsearchapi.rcsb_attributes` object. These are generally easier to use
-    than constructing Attr objects by hand. A complete list of valid attributes is
-    available in the `schema <https://search.rcsb.org/rcsbsearch/v2/metadata/schema>`_.
-
-    * The `range` dictionary requires the following keys:
-     * "from" -> int
-     * "to" -> int
-     * "include_lower" -> bool
-     * "include_upper" -> bool
-    """
-
-    attribute: str
-    type: Optional[str] = STRUCTURE_ATTRIBUTE_SEARCH_SERVICE  # this will be changed later, this is to allow the program to still run. Will not be optional.
-
-    def exact_match(self, value: Union[str, "Value[str]"]) -> AttributeQuery:
-        """Exact match with the value"""
-        if isinstance(value, Value):
-            value = value.value
-        return AttributeQuery(self.attribute, "exact_match", value, self.type)
-
-    def contains_words(
-        self, value: Union[str, "Value[str]", List[str], "Value[List[str]]"]
-    ) -> AttributeQuery:
-        """Match any word within the string.
-
-        Words are split at whitespace. All results which match any word are returned,
-        with results matching more words sorted first.
-        """
-        if isinstance(value, Value):
-            value = value.value
-        if isinstance(value, list):
-            value = " ".join(value)
-        return AttributeQuery(self.attribute, "contains_words", value, self.type)
-
-    def contains_phrase(self, value: Union[str, "Value[str]"]) -> AttributeQuery:
-        """Match an exact phrase"""
-        if isinstance(value, Value):
-            value = value.value
-        return AttributeQuery(self.attribute, "contains_phrase", value, self.type)
-
-    def greater(self, value: TNumberLike) -> AttributeQuery:
-        """Attribute > `value`"""
-        if isinstance(value, Value):
-            value = value.value
-        return AttributeQuery(self.attribute, "greater", value, self.type)
+        # assemble params. This is done differently from before because so many of these are optional here,
+        # and I'm not aware of a method to make inclusions be skipped if a value is NONE when
+        # declaring values on instantiation.
 
-    def less(self, value: TNumberLike) -> AttributeQuery:
-        """Attribute < `value`"""
-        if isinstance(value, Value):
-            value = value.value
-        return AttributeQuery(self.attribute, "less", value, self.type)
+        params = {
+            "value": value,
+            "backbone_distance_tolerance": backbone_distance_tolerance,
+            "side_chain_distance_tolerance": side_chain_distance_tolerance,
+            "angle_tolerance": angle_tolerance,
+            "rmsd_cutoff": rmsd_cutoff,
+            "atom_pairing_scheme": atom_pairing_scheme,
+            "motif_pruning_strategy": motif_pruning_strategy,
+        }
+        if allowed_structures:
+            params["allowed_structures"] = allowed_structures
+        if excluded_structures:
+            params["excluded_structures"] = excluded_structures
+        if exchanges:
+            params["exchanges"] = exchanges
+        if limit:
+            params["limit"] = limit
 
-    def greater_or_equal(self, value: TNumberLike) -> AttributeQuery:
-        """Attribute >= `value`"""
-        if isinstance(value, Value):
-            value = value.value
-        return AttributeQuery(self.attribute, "greater_or_equal", value, self.type)
+        # now call super
 
-    def less_or_equal(self, value: TNumberLike) -> AttributeQuery:
-        """Attribute <= `value`"""
-        if isinstance(value, Value):
-            value = value.value
-        return AttributeQuery(self.attribute, "less_or_equal", value, self.type)
+        super().__init__(service=STRUCTMOTIF_SEARCH_SERVICE, params=params)
 
-    def equals(self, value: TNumberLike) -> AttributeQuery:
-        """Attribute == `value`"""
-        if isinstance(value, Value):
-            value = value.value
-        return AttributeQuery(self.attribute, "equals", value, self.type)
 
-    def range(self, value: Dict[str, Any]) -> AttributeQuery:
-        """Attribute is within the specified half-open range
+class ChemSimilarityQuery(Terminal):
+    """Special case of Terminal for chemical similarity search queries"""
 
+    def __init__(
+        self,
+        value: Optional[str] = None,
+        query_type: ChemSimType = "formula",
+        descriptor_type: Optional[SubsetDescriptorType] = None,
+        match_subset: Optional[bool] = False,
+        match_type: Optional[ChemSimMatchType] = None,
+    ):
+        """
         Args:
-            value: lower and upper bounds `[a, b)`
+            value (Optional[str], optional): chemical formula or descriptor (SMILES or InChI). Defaults to None.
+            query_type (ChemSimType, optional): "formula" or "descriptor". Defaults to "formula".
+            descriptor_type (Optional[SubsetDescriptorType], optional): if "descriptor", whether it's "SMILES" or "InCHI". Defaults to None.
+            match_subset (Optional[bool], optional): if "formula", return chemical components/structures that contain the formula as a subset. Defaults to False.
+            match_type (Optional[ChemSimMatchType], optional): if "descriptor", type of matches to find and return (see below). Defaults to None.
+
+        Guide for "match_type" options:
+        +-----------------------------------+-------------------------------------------+
+        | match_type                        |                                           |
+        +===================================+===========================================+
+        | "graph-relaxed"                   | Similar Ligands (including Stereoisomers) |
+        | "graph-relaxed-stereo"            | Similar Ligands (Stereospecific)          |
+        | "fingerprint-similarity"          | Similar Ligands (Quick screen)            |
+        | "sub-struct-graph-relaxed-stereo" | Substructure (Stereospecific)             |
+        | "sub-struct-graph-relaxed"        | Substructure (including Stereoisomers)    |
+        | "graph-exact"                     | Exact match                               |
+        +-----------------------------------+-------------------------------------------+
         """
-        if isinstance(value, Value):
-            value = value.value
-        return AttributeQuery(self.attribute, "range", value, self.type)
 
-    def exists(self) -> AttributeQuery:
-        """Attribute is defined for the structure"""
-        return AttributeQuery(self.attribute, operator="exists")
+        parameters = {"value": value, "type": query_type}
 
-    def in_(
-        self,
-        value: Union[
-            List[str],
-            List[int],
-            List[float],
-            List[date],
-            Tuple[str, ...],
-            Tuple[int, ...],
-            Tuple[float, ...],
-            Tuple[date, ...],
-            "Value[List[str]]",
-            "Value[List[int]]",
-            "Value[List[float]]",
-            "Value[List[date]]",
-            "Value[Tuple[str, ...]]",
-            "Value[Tuple[int, ...]]",
-            "Value[Tuple[float, ...]]",
-            "Value[Tuple[date, ...]]",
-        ],
-    ) -> AttributeQuery:
-        """Attribute is contained in the list of values"""
-        if isinstance(value, Value):
-            value = value.value
-        return AttributeQuery(self.attribute, operator="in", value=value)
+        if query_type == "formula":
+            parameters["match_subset"] = match_subset
 
-    # Need ignore[override] because typeshed restricts __eq__ return value
-    # https://github.com/python/mypy/issues/2783
-    @overload  # type: ignore[override]
-    def __eq__(self, value: "Attr") -> bool:
-        ...
+        elif query_type == "descriptor":
+            parameters["descriptor_type"] = descriptor_type
+            parameters["match_type"] = match_type
 
-    @overload  # type: ignore[override]
-    def __eq__(
-        self,
-        value: Union[
-            str,
-            int,
-            float,
-            date,
-            "Value[str]",
-            "Value[int]",
-            "Value[float]",
-            "Value[date]",
-        ],
-    ) -> Terminal:
-        ...
+        super().__init__(service=CHEM_SIM_SEARCH_SERVICE, params=parameters)
 
-    def __eq__(
-        self,
-        value: Union[
-            "Attr",
-            str,
-            int,
-            float,
-            date,
-            "Value[str]",
-            "Value[int]",
-            "Value[float]",
-            "Value[date]",
-        ],
-    ) -> Union[Terminal, bool]:  # type: ignore[override]
-        if isinstance(value, Attr):
-            return self.attribute == value.attribute
-        if isinstance(value, Value):
-            value = value.value
-        if isinstance(value, str):
-            return self.exact_match(value)
-        elif (
-            isinstance(value, date)
-            or isinstance(value, float)
-            or isinstance(value, int)
-        ):
-            return self.equals(value)
-        else:
-            return NotImplemented
 
-    @overload  # type: ignore[override]
-    def __ne__(self, value: "Attr") -> bool:
-        ...
+@dataclass(frozen=True)
+class Group(Query):
+    """AND and OR combinations of queries"""
 
-    @overload  # type: ignore[override]
-    def __ne__(
-        self,
-        value: Union[
-            str,
-            int,
-            float,
-            date,
-            "Value[str]",
-            "Value[int]",
-            "Value[float]",
-            "Value[date]",
-        ],
-    ) -> Terminal:
-        ...
+    operator: TAndOr
+    nodes: Iterable[Query] = ()
 
-    def __ne__(
-        self,
-        value: Union[
-            "Attr",
-            str,
-            int,
-            float,
-            date,
-            "Value[str]",
-            "Value[int]",
-            "Value[float]",
-            "Value[date]",
-        ],
-    ) -> Union[Terminal, bool]:  # type: ignore[override]
-        if isinstance(value, Attr):
-            return self.attribute != value.attribute
-        if isinstance(value, Value):
-            value = value.value
-        return ~(self == value)
+    def to_dict(self):
+        return dict(
+            type="group",
+            logical_operator=self.operator,
+            nodes=[node.to_dict() for node in self.nodes],
+        )
 
-    def __lt__(self, value: TNumberLike) -> Terminal:
-        if isinstance(value, Value):
-            value = value.value
-        return self.less(value)
+    def __invert__(self):
+        if self.operator == "and":
+            return Group("or", [~node for node in self.nodes])
 
-    def __le__(self, value: TNumberLike) -> Terminal:
-        if isinstance(value, Value):
-            value = value.value
-        return self.less_or_equal(value)
+    def __and__(self, other: Query) -> Query:
+        # Combine nodes if possible
+        if self.operator == "and":
+            if isinstance(other, Group):
+                if other.operator == "and":
+                    return Group("and", (*self.nodes, *other.nodes))
+            elif isinstance(other, Query):
+                return Group("and", (*self.nodes, other))
+            else:
+                return NotImplemented
 
-    def __gt__(self, value: TNumberLike) -> Terminal:
-        if isinstance(value, Value):
-            value = value.value
-        return self.greater(value)
+        return super().__and__(other)
 
-    def __ge__(self, value: TNumberLike) -> Terminal:
-        if isinstance(value, Value):
-            value = value.value
-        return self.greater_or_equal(value)
+    def __or__(self, other: Query) -> Query:
+        # Combine nodes if possible
+        if self.operator == "or":
+            if isinstance(other, Group):
+                if other.operator == "or":
+                    return Group("or", (*self.nodes, *other.nodes))
+            elif isinstance(other, Query):
+                return Group("or", (*self.nodes, other))
+            else:
+                return NotImplemented
 
-    def __bool__(self) -> Terminal:  # pylint: disable=invalid-bool-returned
-        return self.exists()
+        return super().__or__(other)
 
-    def __contains__(
-        self, value: Union[str, List[str], "Value[str]", "Value[List[str]]"]
-    ) -> Terminal:
-        """Maps to contains_words or contains_phrase depending on the value passed.
+    def _assign_ids(self, node_id=0) -> Tuple[Query, int]:
+        nodes = []
+        changed = False
+        for node in self.nodes:
+            assigned = node._assign_ids(node_id)
+            nodes.append(assigned[0])
+            node_id = assigned[1]
+            # Track whether any nodes were modified
+            changed = changed or assigned[0] is node
+        if changed:
+            return (Group(self.operator, nodes), node_id)
+        else:
+            return (self, node_id)
 
-        * `"value" in attr` maps to `attr.contains_phrase("value")` for simple values.
-        * `["value"] in attr` maps to `attr.contains_words(["value"])` for lists and
-          tuples.
-        """
-        if isinstance(value, Value):
-            value = value.value
-        if isinstance(value, list):
-            if len(value) == 0 or isinstance(value[0], str):
-                return self.contains_words(value)
-            else:
-                return NotImplemented
+    def __str__(self):
+        """"""  # hide in documentation
+        if self.operator == "and":
+            return f"({' & '.join((str(n) for n in self.nodes))})"
+        elif self.operator == "or":
+            return f"({' | '.join((str(n) for n in self.nodes))})"
         else:
-            return self.contains_phrase(value)
+            raise ValueError("Illegal Operator")
 
 
 # Type for functions returning Terminal
@@ -985,9 +1057,7 @@ def exact_match(self, value: Union[str, "Value[str]"]) -> Query:
         ...
 
     @_attr_delegate(Attr.contains_words)
-    def contains_words(
-        self, value: Union[str, "Value[str]", List[str], "Value[List[str]]"]
-    ) -> Query:
+    def contains_words(self, value: Union[str, "Value[str]", List[str], "Value[List[str]]"]) -> Query:
         ...
 
     @_attr_delegate(Attr.contains_phrase)
@@ -1073,11 +1143,7 @@ def __eq__(
         ],
     ) -> Union[Query, bool]:  # type: ignore[override]
         if isinstance(value, PartialQuery):
-            return (
-                self.attr == value.attr
-                and self.query == value.query
-                and self.operator == value.operator
-            )
+            return self.attr == value.attr and self.query == value.query and self.operator == value.operator
 
         if self.operator == "and":
             return self.query & (self.attr == value)
@@ -1140,14 +1206,8 @@ def __gt__(self, value: TNumberLike) -> Query:
     def __ge__(self, value: TNumberLike) -> Query:
         ...
 
-    @_attr_delegate(Attr.__bool__)
-    def __bool__(self) -> Query:
-        ...
-
     @_attr_delegate(Attr.__contains__)
-    def __contains__(
-        self, value: Union[str, List[str], "Value[str]", "Value[List[str]]"]
-    ) -> Query:
+    def __contains__(self, value: Union[str, List[str], "Value[str]", "Value[List[str]]"]) -> Query:
         ...
 
 
@@ -1203,44 +1263,28 @@ def __ne__(self, attr: Union["Value", Attr]) -> Union[bool, Terminal]:
     def __lt__(self, attr: Attr) -> Terminal:
         if not isinstance(attr, Attr):
             return NotImplemented
-        if not (
-            isinstance(self.value, int)
-            or isinstance(self.value, float)
-            or isinstance(self.value, date)
-        ):
+        if not (isinstance(self.value, int) or isinstance(self.value, float) or isinstance(self.value, date)):
             return NotImplemented
         return attr.greater(self.value)
 
     def __le__(self, attr: Attr) -> Terminal:
         if not isinstance(attr, Attr):
             return NotImplemented
-        if not (
-            isinstance(self.value, int)
-            or isinstance(self.value, float)
-            or isinstance(self.value, date)
-        ):
+        if not (isinstance(self.value, int) or isinstance(self.value, float) or isinstance(self.value, date)):
             return NotImplemented
         return attr.greater_or_equal(self.value)
 
     def __gt__(self, attr: Attr) -> Terminal:
         if not isinstance(attr, Attr):
             return NotImplemented
-        if not (
-            isinstance(self.value, int)
-            or isinstance(self.value, float)
-            or isinstance(self.value, date)
-        ):
+        if not (isinstance(self.value, int) or isinstance(self.value, float) or isinstance(self.value, date)):
             return NotImplemented
         return attr.less(self.value)
 
     def __ge__(self, attr: Attr) -> Terminal:
         if not isinstance(attr, Attr):
             return NotImplemented
-        if not (
-            isinstance(self.value, int)
-            or isinstance(self.value, float)
-            or isinstance(self.value, date)
-        ):
+        if not (isinstance(self.value, int) or isinstance(self.value, float) or isinstance(self.value, date)):
             return NotImplemented
         return attr.less_or_equal(self.value)
 
@@ -1272,6 +1316,7 @@ class Facet:
     """Facets can be used (in conjunction with the facet() function on a Query) in order to group and perform calculations and statistics on PDB data.
     Facets arrange search results into categories (buckets) based on the requested field values.
     """
+
     def __init__(
         self,
         name: str,
@@ -1282,7 +1327,7 @@ def __init__(
         min_interval_population: Optional[int] = None,
         max_num_intervals: Optional[int] = None,
         precision_threshold: Optional[int] = None,
-        nested_facets: Optional[Union["Facet", "FilterFacet", List[Union["Facet", "FilterFacet"]]]] = None
+        nested_facets: Optional[Union["Facet", "FilterFacet", List[Union["Facet", "FilterFacet"]]]] = None,
     ):
         """Initialize Facet object for use in a faceted query.
 
@@ -1357,7 +1402,7 @@ def __init__(
         operator: Literal["equals", "greater", "greater_or_equal", "less", "less_or_equal", "range", "exact_match", "in", "exists"],
         value: Optional[Union[str, int, float, bool, Range, List[str], List[int], List[float]]] = None,
         negation: bool = False,
-        case_sensitive: bool = False
+        case_sensitive: bool = False,
     ):
         self.attribute = attribute
         self.operator = operator
@@ -1378,7 +1423,7 @@ class GroupFilter:
     """
 
     def __init__(self, logical_operator: TAndOr, nodes: List[Union["TerminalFilter", "GroupFilter"]]):
-        self.logical_operator = logical_operator,
+        self.logical_operator = (logical_operator,)
         self.nodes = nodes
 
     def to_dict(self):
@@ -1388,11 +1433,7 @@ def to_dict(self):
 class FilterFacet:
     """Facet queries using Filters"""
 
-    def __init__(
-        self,
-        filters: Union[TerminalFilter, GroupFilter],
-        facets: Union[Facet, "FilterFacet", List[Union[Facet, "FilterFacet"]]]
-    ):
+    def __init__(self, filters: Union[TerminalFilter, GroupFilter], facets: Union[Facet, "FilterFacet", List[Union[Facet, "FilterFacet"]]]):
         self.filter = filters
         self.facets = facets if isinstance(facets, list) else [facets]
 
@@ -1424,7 +1465,7 @@ def __init__(
         rows: int = 10000,
         return_content_type: List[ReturnContentType] = ["experimental"],
         results_verbosity: VerbosityLevel = "compact",
-        facets: Optional[Union[Facet, FilterFacet, List[Union[Facet, FilterFacet]]]] = None
+        facets: Optional[Union[Facet, FilterFacet, List[Union[Facet, FilterFacet]]]] = None,
         # pylint: disable=dangerous-default-value
     ):
         self.query_id = Session.make_uuid()
@@ -1471,12 +1512,8 @@ def _make_params(self, start=0):
     def _single_query(self, start=0) -> Optional[Dict]:
         "Fires a single query"
         params = self._make_params(start)
-        logging.debug(
-            "Querying %s for results %s-%s", self.url, start, start + self.rows - 1
-        )
-        response = requests.get(
-            self.url, {"json": json.dumps(params, separators=(",", ":"))}, timeout=None
-        )
+        logging.debug("Querying %s for results %s-%s", self.url, start, start + self.rows - 1)
+        response = requests.get(self.url, {"json": json.dumps(params, separators=(",", ":"))}, timeout=None)
         response.raise_for_status()
         if response.status_code == requests.codes.ok:
             return response.json()
@@ -1541,13 +1578,13 @@ def iquery(self, limit: Optional[int] = None) -> List[str]:
     def rcsb_query_editor_url(self) -> str:
         """URL to edit this query in the RCSB PDB query editor"""
         data = json.dumps(self._make_params(), separators=(",", ":"))
-        return (
-            f"https://search.rcsb.org/query-editor.html?json={urllib.parse.quote(data)}"
-        )
+        return f"https://search.rcsb.org/query-editor.html?json={urllib.parse.quote(data)}"
 
     def rcsb_query_builder_url(self) -> str:
         """URL to view this query on the RCSB PDB website query builder"""
         params = self._make_params()
         params["request_options"]["paginate"]["rows"] = 25
+        if "results_verbosity" in params["request_options"]:
+            _ = params["request_options"].pop("results_verbosity")
         data = json.dumps(params, separators=(",", ":"))
         return f"https://www.rcsb.org/search?request={urllib.parse.quote(data)}"
diff --git a/rcsbsearchapi/update_schema.py b/rcsbsearchapi/update_schema.py
index dced633..d8c163f 100644
--- a/rcsbsearchapi/update_schema.py
+++ b/rcsbsearchapi/update_schema.py
@@ -4,7 +4,7 @@
 from pathlib import Path
 
 try:
-    from .schema import _fetch_schema
+    from .search import SCHEMA  # instance of Schema
 except Exception:
     # ignore errors that may occur parsing the schema
     pass
@@ -13,5 +13,5 @@
     path = Path(__file__).parent.joinpath("resources", "metadata_schema.json")
     print(path)
     with open(path, "wt", encoding="utf-8") as file:
-        latest = _fetch_schema("this will be replaced with the URL once this file is in use. ")
+        latest = SCHEMA._fetch_schema("this will be replaced with the URL once this file is in use. ")
         json.dump(latest, file)


Test patch:
diff --git a/tests/testschema.py b/tests/testschema.py
index ca62cd5..00d7534 100644
--- a/tests/testschema.py
+++ b/tests/testschema.py
@@ -24,8 +24,8 @@
 import unittest
 
 from rcsbsearchapi import rcsb_attributes as attrs
-from rcsbsearchapi.schema import _load_json_schema, _load_chem_schema, _fetch_schema
-from rcsbsearchapi.const import STRUCTURE_ATTRIBUTE_SCHEMA_URL, CHEMICAL_ATTRIBUTE_SCHEMA_URL
+from rcsbsearchapi import SCHEMA
+from rcsbsearchapi.const import STRUCTURE_ATTRIBUTE_SCHEMA_URL, CHEMICAL_ATTRIBUTE_SCHEMA_URL, STRUCTURE_ATTRIBUTE_SCHEMA_FILE, CHEMICAL_ATTRIBUTE_SCHEMA_FILE
 
 
 logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s]-%(module)s.%(funcName)s: %(message)s")
@@ -54,8 +54,8 @@ def testSchema(self):
 
     def testSchemaVersion(self):
         # Check structure attribute schema version
-        webSchema = _fetch_schema(STRUCTURE_ATTRIBUTE_SCHEMA_URL)
-        localSchema = _load_json_schema()
+        webSchema = SCHEMA._fetch_schema(STRUCTURE_ATTRIBUTE_SCHEMA_URL)
+        localSchema = SCHEMA._load_json_schema(STRUCTURE_ATTRIBUTE_SCHEMA_FILE)
         webVer = webSchema.get("$comment").split()[-1]
         localVer = localSchema.get("$comment").split()[-1]
         ok = len(localVer.split(".")) == 3 and len(webVer.split(".")) == 3
@@ -68,8 +68,8 @@ def testSchemaVersion(self):
         self.assertTrue(ok)
         logger.info("Metadata schema tests results: local version (%r) and web version (%s)", localVer, webVer)
         # Check chemical attribute schema version
-        webSchema = _fetch_schema(CHEMICAL_ATTRIBUTE_SCHEMA_URL)
-        localSchema = _load_chem_schema()
+        webSchema = SCHEMA._fetch_schema(CHEMICAL_ATTRIBUTE_SCHEMA_URL)
+        localSchema = SCHEMA._load_json_schema(CHEMICAL_ATTRIBUTE_SCHEMA_FILE)
         webVer = webSchema.get("$comment").split()[-1]
         localVer = localSchema.get("$comment").split()[-1]
         ok = len(localVer.split(".")) == 3 and len(webVer.split(".")) == 3
@@ -84,26 +84,47 @@ def testSchemaVersion(self):
 
     def testFetchSchema(self):
         # check fetching of structure attribute schema
-        fetchSchema = _fetch_schema(STRUCTURE_ATTRIBUTE_SCHEMA_URL)
+        fetchSchema = SCHEMA._fetch_schema(STRUCTURE_ATTRIBUTE_SCHEMA_URL)
         ok = fetchSchema is not None
         logger.info("ok is %r", ok)
         self.assertTrue(ok)
-        fetchSchema = _fetch_schema(CHEMICAL_ATTRIBUTE_SCHEMA_URL)
+        fetchSchema = SCHEMA._fetch_schema(CHEMICAL_ATTRIBUTE_SCHEMA_URL)
         ok = fetchSchema is not None
         logger.info("ok is %r", ok)
         self.assertTrue(ok)
         errorURL = "https://httpbin.org/status/404"
-        fetchSchema = _fetch_schema(errorURL)
+        fetchSchema = SCHEMA._fetch_schema(errorURL)
         ok = fetchSchema is None
         logger.info("ok is %r", ok)
         self.assertTrue(ok)
 
+    def testRcsbAttrs(self):
+        with self.subTest(msg="1. Check type and descriptions exist for attributes"):
+            for attr in attrs:
+                attr_dict = vars(attr)
+                desc = attr_dict["description"]
+                self.assertIsNotNone(desc)
+
+        with self.subTest(msg="2. Check searching for attribute details"):
+            attr_details = attrs.get_attribute_details("drugbank_info.drug_groups")
+            for obj_attr in ["attribute", "type", "description"]:
+                self.assertIn(obj_attr, vars(attr_details).keys())
+
+            # special case because rcsb_id is in both structure and chemical attributes
+            attr_dict = vars(attrs.get_attribute_details("rcsb_id"))
+            self.assertIsInstance(attr_dict["type"], list)
+            self.assertIsInstance(attr_dict["description"], list)
+
+            attr_details = attrs.get_attribute_details("foo")
+            self.assertIsNone(attr_details)
+
 
 def buildSchema():
     suiteSelect = unittest.TestSuite()
     suiteSelect.addTest(SchemaTests("testSchema"))
     suiteSelect.addTest(SchemaTests("testSchemaVersion"))
     suiteSelect.addTest(SchemaTests("testFetchSchema"))
+    suiteSelect.addTest(SchemaTests("testRcsbAttrs"))
 
     return suiteSelect
 
diff --git a/tests/testsearch.py b/tests/testsearch.py
index a0ab00a..f3a739a 100644
--- a/tests/testsearch.py
+++ b/tests/testsearch.py
@@ -26,9 +26,10 @@
 from itertools import islice
 import requests
 from rcsbsearchapi.const import CHEMICAL_ATTRIBUTE_SEARCH_SERVICE, STRUCTURE_ATTRIBUTE_SEARCH_SERVICE, RETURN_UP_URL
-from rcsbsearchapi import Attr, Group, Session, TextQuery, Value
+from rcsbsearchapi import Attr, Group, TextQuery
 from rcsbsearchapi import rcsb_attributes as attrs
 from rcsbsearchapi.search import PartialQuery, Terminal, AttributeQuery, SequenceQuery, SeqMotifQuery, StructSimilarityQuery, fileUpload, StructureMotifResidue, StructMotifQuery
+from rcsbsearchapi.search import Session, Value
 from rcsbsearchapi.search import ChemSimilarityQuery
 from rcsbsearchapi.search import Facet, Range, TerminalFilter, GroupFilter, FilterFacet
 
@@ -200,7 +201,7 @@ def testMalformedQuery(self):
         """Attempt to make an invalid, malformed query. Upon finding an error,
         catch the error and pass, continuing tests. An exception is only thrown
         if the query somehow completes successfully. """
-        q1 = AttributeQuery("invalid_identifier", operator="exact_match", value="ERROR")
+        q1 = AttributeQuery("invalid_identifier", operator="exact_match", value="ERROR", service="text")
         session = Session(q1)
         try:
             set(session)
@@ -274,7 +275,7 @@ def exampleQuery2(self):
 
     def testAttribute(self):
         """Test the attributes - make sure that they are assigned correctly, etc. """
-        attr = Attr("attr")
+        attr = Attr(attribute="attr", type="type")
 
         term = attr == "value"
         ok = isinstance(term, Terminal)
@@ -305,7 +306,7 @@ def testFreeText(self):
 
     def testPartialQuery(self):
         """Test the ability to perform partial queries. """
-        query = Attr("a").equals("aval").and_("b")
+        query = Attr(attribute="a", type="text").equals("aval").and_("b")
 
         ok = isinstance(query, PartialQuery)
         self.assertTrue(ok)
@@ -330,7 +331,7 @@ def testPartialQuery(self):
         self.assertTrue(ok)
         ok = query.nodes[1].params.get("value") == "bval"
 
-        query = query.and_(Attr("c") < 5)
+        query = query.and_(Attr("c", "text") < 5)
         ok = len(query.nodes) == 3
         self.assertTrue(ok)
         ok = query.nodes[2].params.get("attribute") == "c"
@@ -344,7 +345,7 @@ def testPartialQuery(self):
 
         ok = isinstance(query, PartialQuery)
         self.assertTrue(ok)
-        ok = query.attr == Attr("d")
+        ok = query.attr == Attr("d", "text")
         self.assertTrue(ok)
         ok = query.operator == "or"
         self.assertTrue(ok)
@@ -369,7 +370,7 @@ def testPartialQuery(self):
 
     def testOperators(self):
         """Test operators such as contain and in. """
-        q1 = attrs.rcsb_id.in_(["4HHB", "2GS2"])  # test in
+        q1 = attrs.rcsb_entry_container_identifiers.rcsb_id.in_(["4HHB", "2GS2"])  # test in
         results = list(q1())
         ok = len(results) == 2
         logger.info("In search results length: (%d) ok: (%r)", len(results), ok)
@@ -977,6 +978,7 @@ def testResultsCount(self):
             CHEMICAL_ATTRIBUTE_SEARCH_SERVICE  # this constant specifies "text_chem" service
         )
         result = q3.count()
+        print("Q3 COUNT", result)
         ok = result == len(list(q3()))
         self.assertTrue(ok)
         logger.info("Counting results of chemical Attribute query: (%d), ok : (%r)", result, ok)
@@ -1032,7 +1034,7 @@ def testResultsCount(self):
         logger.info("Counting results of Chemical similarity query: (%d), ok : (%r)", result, ok)
 
         ok = False
-        q9 = AttributeQuery("invalid_identifier", operator="exact_match", value="ERROR")
+        q9 = AttributeQuery("invalid_identifier", operator="exact_match", value="ERROR", service="textx")
         try:
             _ = q9.count()
         except requests.HTTPError: