Category Search Algorithm Documentation
Overview
The thalabus_query() method in RagService implements a sophisticated query optimization system that intelligently delegates category-only expressions to the OntologyService for efficient Cypher-level processing. The system analyzes queries, identifies category-only subtrees, and executes them as single optimized Cypher queries instead of splitting and combining results in Python.
Query Optimization Through Internal Representation
The @ Delegation Mechanism
The system uses an internal representation where category-only subtrees are marked with @ symbols. This is purely internal and not exposed to users.
Example:
- User input: "asylum seeker" AND COUNTRY_OF_ORIGIN=IQ
- Internal representation: "asylum seeker" AND @(COUNTRY_OF_ORIGIN=IQ)
- The @ marks that COUNTRY_OF_ORIGIN=IQ can be delegated to categories_query()
- The semantic search part remains in Python for processing
Complex example:
- User input: (DEPORTATION_DESTINATION_COUNTRY=IQ AND NOT DISABILITY_TYPE=TRAUMA) OR COUNTRY_OF_ORIGIN=SY
- Internal representation: @((DEPORTATION_DESTINATION_COUNTRY=IQ AND NOT DISABILITY_TYPE=TRAUMA) OR COUNTRY_OF_ORIGIN=SY)
- The entire expression is category-only, so it's marked with @ and delegated as a single Cypher query
Optimization Process
The query optimization happens in three phases:
- Parsing Phase:
- Tokenize the input query into tokens
-
Build an Abstract Syntax Tree (AST) representing the logical structure
-
Marking Phase (
_mark_category_only_subtrees): - Recursively traverse the AST from innermost to outermost expressions
- For each subtree, check if all categories exist in the ontology using
is_categories_only_query() - Mark category-only nodes with
is_category_only=Trueand store the serialized query incategory_queryfield -
Non-category parts (semantic/text search) remain unmarked
-
Evaluation Phase (
_evaluate_thalabus_ast): - Check if node is marked as category-only
- If yes: delegate entire subtree to
ontology_service.categories_query()for efficient Cypher execution - If no: recursively evaluate children and combine results using set operations (union for OR, intersection for AND)
How It Works
Input Format
The method accepts a boolean expression query with the following syntax:
- Simple category:
IS_DEAD— matches documents with any keyword in the IS_DEAD category - Category with values:
DUBLIN_ARTICLE=ART_13,ART_25_2— matches documents with keywords "ART_13" OR "ART_25_2" in the DUBLIN_ARTICLE category - Semantic search (RAG): Any string in double quotes (e.g.,
"Bob the Cat") is treated as a semantic search term. The system will use retrieval-augmented generation (RAG) to find documents semantically related to the phrase. Semantic search terms can be combined with category filters and boolean logic. - Exact text search (case-insensitive): Any string in single quotes (e.g.,
'bob') is treated as an exact text search, case-insensitive. This uses a text index to match all case variations (e.g., Bob, BOB, bob). - Exact text search (case-sensitive): Any string in single quotes preceded by
@(e.g.,@'Bob') is treated as an exact text search, case-sensitive. Only exact matches for the case will be returned. - Boolean operators:
AND,OR,NOTwith standard precedence (AND before OR before NOT). Boolean operators are case-insensitive (ANDis identical toand). TheNOToperator negates a single term or grouped expression, e.g.,NOT COUNTRY=IQorNOT (SPECIES=CAT AND AGE=ADULT). - Parentheses: For grouping, e.g.,
(expr1 OR expr2) AND expr3 - AgentPromptAugmenter contract: AgentPromptAugmenter must always return a valid search query. General-information requests are represented as quoted semantic queries; the augmenter must not emit
TRUE, a boolean value, null, or an empty query.
Examples
- Category only
IS_DEAD-
DUBLIN_ARTICLE=ART_13,ART_25_2 -
Semantic search with category
-
"Bob the Cat" AND SPECIES=CAT- Semantically searches for a cat named Bob, restricted to documents where SPECIES is CAT.
-
Exact text search (case-insensitive)
-
'bob'- Finds documents containing "bob", "Bob", "BOB", etc.
-
Exact text search (case-sensitive)
-
@'Bob'- Finds documents containing exactly "Bob" (case-sensitive).
-
NOT operator example
NOT COUNTRY=IQ- Finds documents that are NOT in the IQ category.
-
"Bob the Cat" AND NOT SPECIES=DOG- Semantically searches for Bob the Cat, but excludes documents where the species is DOG.
-
Combined complex example
-
("Bob the Cat" OR 'bob') AND (SPECIES=CAT OR SPECIES=DOG) AND NOT IS_DEAD- Finds documents semantically related to "Bob the Cat" or containing the exact text "bob" (any case), where the species is either CAT or DOG, but excludes documents where IS_DEAD is true.
-
General-information semantic query
"steps and procedures for returning a rented car from a car rental company"- Represents a general-information request as a valid semantic query. AgentPromptAugmenter uses this form rather than emitting
TRUE.
- Represents a general-information request as a valid semantic query. AgentPromptAugmenter uses this form rather than emitting
Common Mistakes
1. Missing Boolean Operator Between Expressions
❌ Incorrect:
CATEGORY1 NOT CATEGORY2
✅ Correct:
CATEGORY1 AND NOT CATEGORY2
Error message: Unexpected tokens after parsing: ['NOT', ('CATEGORY', 'CATEGORY2')]
Explanation: The NOT operator must be preceded by AND or OR. You cannot place two expressions next to each other without a boolean operator.
2. Standalone NOT Operator
❌ Incorrect:
NOT COUNTRY=IQ
✅ Correct:
TRUE AND NOT COUNTRY=IQ
or as part of a larger expression:
COUNTRY=SY AND NOT COUNTRY=IQ
Error message: NOT operator cannot be used standalone. Use it with AND: "X AND NOT Y"
Explanation: While NOT can be parsed, it must be combined with another expression using AND or OR.
3. Missing Quotes for Multi-Word Searches
❌ Incorrect:
Bob the Cat AND SPECIES=CAT
✅ Correct:
"Bob the Cat" AND SPECIES=CAT
Error message: Tokenization errors or unexpected parsing results.
Explanation: Multi-word phrases must be enclosed in double quotes for semantic search or single quotes for exact text search.
Algorithm Steps
Step 1: Tokenization (_tokenize_thalabus_query)
- Parses the input string into tokens
- Identifies: parentheses, operators (AND/OR/NOT), quoted strings, and category expressions
- Handles quote escaping for semantic/text search terms
Step 2: Parsing (_parse_thalabus_expression, _parse_thalabus_and_expression, _parse_thalabus_not_expression, _parse_thalabus_primary)
- Uses recursive descent parsing to build an Abstract Syntax Tree (AST)
- Respects operator precedence: NOT (highest), AND, OR (lowest)
- Handles parentheses for grouping
- Creates a tree structure representing the logical expression
Step 3: Marking Category-Only Subtrees (_mark_category_only_subtrees)
- Recursively processes the AST from innermost to outermost nodes
- For each node, serializes the subtree back to a query string
- Calls
ontology_service.is_categories_only_query()to check if all categories exist - Marks nodes with
is_category_only=Trueif they can be delegated - Stores the serialized query in the
category_queryfield
Step 4: Evaluation (_evaluate_thalabus_ast)
- Checks if current node is marked as category-only
- If yes: delegates entire subtree to
ontology_service.categories_query()for Cypher execution - If no: recursively evaluates children and combines results:
- AND: intersection of document ID sets
- OR: union of document ID sets
- NOT: complement of document ID set (requires fetching all docs)
Two independent parser implementations (found during documentation review): The query language described in this document is parsed twice, by two separate implementations that must be kept in sync when the grammar changes: -
RagService(_tokenize_thalabus_query,_parse_thalabus_expression,_mark_category_only_subtrees,_evaluate_thalabus_ast) — the primary entry point, documented in this file. -OntologyService.categories_query()uses its own tokenizer/parser (_tokenize_query,_parse_expression,_generate_cypher_from_ast) to turn a category-only subtree directly into a CypherWHEREclause. This is the delegation target described in the "@ Delegation Mechanism" section above. A grammar change (new operator, new quoting rule, etc.) must be applied to both parsers.
Neo4j Graph Pattern
For category-only queries, the algorithm generates optimized Cypher queries that search for this pattern in Neo4j:
(d:Document)-[:FOLLOWS]->(o:Ontology)-[:CONTAINS]->(kc:KeywordCategory)
with (d)-[:HAS_CATEGORY]->(c:Category)-[:OF_CATEGORY]->(kc)
Example
Input query:
IS_DEAD OR (DUBLIN_ARTICLE=ART_13,ART_25_2 AND COUNTRY=IQ)
Internal representation after marking:
@(IS_DEAD OR (DUBLIN_ARTICLE=ART_13,ART_25_2 AND COUNTRY=IQ))
Execution:
- The entire expression is marked as category-only
- It's delegated to categories_query() as a single Cypher query
- The Cypher engine handles all boolean logic and category filtering efficiently
Efficiency Benefits
- Cypher-level Optimization: Complex boolean expressions are executed as single Cypher queries, leveraging Neo4j's query optimizer
- Reduced Network Roundtrips: Category-only queries execute in one database roundtrip instead of multiple
- Better Index Usage: Neo4j can apply indexes across the entire boolean expression
- Intelligent Splitting: Mixed queries (semantic + category) are split optimally - only the non-category parts remain in Python
- Lazy Evaluation: NOT operations only fetch all document IDs when needed
Key Design Decisions
- Internal Representation: The
@notation is purely internal and not part of the user-facing query language - Recursive Marking: Category-only detection works from innermost to outermost, allowing complex nested expressions to be optimized
- Validation Cache: The
is_categories_only_query()method validates category existence before marking - Set Operations: Python-side evaluation uses set operations (intersection, union, complement) for efficient result combination
- Graceful Degradation: If a category doesn't exist, the subtree is marked as non-category, and evaluation falls back to Python processing
Usage Example
from src.services.rag_service import get_rag_service
service = get_rag_service()
# Simple category search (fully optimized)
results, _ = service.thalabus_query("Juris", "COUNTRY_OF_ORIGIN=IQ")
# Log shows: Delegating category-only subtree to categories_query(): COUNTRY_OF_ORIGIN=IQ
# Complex category search (fully optimized)
results, _ = service.thalabus_query(
"Juris",
"(COUNTRY_OF_ORIGIN=IQ OR COUNTRY_OF_ORIGIN=SY) AND NOT IS_DEAD"
)
# Log shows: Delegating category-only subtree to categories_query(): ((COUNTRY_OF_ORIGIN=IQ OR COUNTRY_OF_ORIGIN=SY) AND NOT (IS_DEAD))
# Mixed search (partially optimized)
results, _ = service.thalabus_query(
"Juris",
'"refugee crisis" AND COUNTRY_OF_ORIGIN=IQ'
)
# Logs show:
# - Semantic search "refugee crisis" processed separately
# - Delegating category-only subtree to categories_query(): COUNTRY_OF_ORIGIN=IQ
# - Results combined with AND operation
Testing
Run the test script to verify functionality:
python src/services/rag_service.py
Look for log messages showing:
- RagService._mark_category_only_subtrees: Marked as category-only: — Subtrees that were optimized
- RagService._mark_category_only_subtrees: Not category-only: — Subtrees that require Python evaluation
- RagService._evaluate_thalabus_ast: Delegating category-only subtree to categories_query(): — Actual delegation points
- RagService.thalabus_query: Optimized query representation: — The full query with internal @ marks
Error Handling
- Invalid syntax raises
ValueErrorwith descriptive message - Non-existent categories cause the subtree to be marked as non-category
- Neo4j errors are caught and logged
- Returns empty dictionary on error
- All results logged at INFO level for debugging
Automatic Reranking (Dec 2025)
The thalabus_query() method now supports automatic reranking of results:
def thalabus_query(
self,
query: str,
ontology_id: Optional[str] = None,
subset_of_datasetids: Optional[List[str]] = None,
automatically_rerank: bool = False,
create_dataset: Optional[Union[bool, str]] = None,
session_id: Optional[str] = None,
userid: Optional[str] = None
) -> Tuple[List[dict], Optional[List[str]]]:
# Returns (document_list, dataset_ids)
# dataset_ids is a list with single dataset ID if created, None otherwise
Parameters
query: The query string in thalabus query language (see syntax above)ontology_id: Optional ontology ID to restrict category matching. IfNone, searches across any ontology.subset_of_datasetids: Optional list of parent dataset IDs to restrict search results. When provided, only documents that belong to at least one of the specified parent datasets will be included in results. Uses OR logic: documents need to be in ANY of the specified datasets, not all of them. This enables filtering query results to specific document collections.automatically_rerank: IfTrue, enables automatic reranking of results using the reranking service (default:False)create_dataset: IfTrue, creates a dataset with auto-generated name. If string, uses that as the dataset name. IfNoneorFalse, no dataset is created.session_id: Required for dataset creation. Used to generate unique names and associate dataset with session.userid: Optional user ID for dataset creation. If not provided, extracted fromsession_id.
How Reranking Works
When automatically_rerank=True:
- Semantic/text search executes normally and returns initial results
- Statistical filtering (standing out results) is disabled when reranking is enabled
- Reranking service fetches chapters and scores them in parallel
- Results are re-sorted by reranking scores (descending)
Example with Reranking
results, _ = service.thalabus_query('"cleanup with the mop"', automatically_rerank=True)
# Returns tuple: ([docid-543 (score: 13.03), docid-544 (score: 9.80), ...], None)
See RERANKING_ARCHITECTURE_DEC2025.md for complete details.
Dataset Creation (Jan 2026)
The thalabus_query() method now supports automatic dataset creation from query results:
Parameters
create_dataset: IfTrue, creates a dataset with auto-generated name. If string, uses that as the dataset name.session_id: Required for dataset creation. Used to generate unique names and associate dataset with session.userid: Optional user ID. If not provided, extracted fromsession_id(format: "userid-sessionid").
How Dataset Creation Works
When create_dataset is set:
- Query executes normally and returns fragment results (chapters, paragraphs, summaries)
- If
subset_of_datasetidsis provided, only fragments from documents in those parent datasets are included - Dataset name is determined:
- If
create_dataset=True: Auto-generated with prefix "query_" (e.g., "query_001") - If
create_dataset="my_dataset": Uses the provided name - Dataset is created with:
- All fragment IDs from query results (preserves actual matched fragments)
- Fragment scores as weights (truncated to 5 decimal places)
- Metadata: query string, ontology_id, item counts, fragment type breakdown
- Dataset ID is returned in a list as the second tuple element
Important: Datasets now contain the actual fragments that matched the query, not aggregated documents. This means:
- Semantic searches ("text") → mostly c:Chapter and p:Paragraph nodes
- Category searches (CATEGORY) → d:Document nodes (categories are document-level)
- Text searches ('text') → mix of c:Chapter nodes and keyword fragments
- Mixed queries → combination of all fragment types
Examples with Dataset Creation
# Auto-generate dataset name
results, dataset_ids = service.thalabus_query(
'"refugee crisis" AND COUNTRY_OF_ORIGIN=IQ',
ontology_id="Juris",
create_dataset=True,
session_id="user123-session456"
)
# Returns: (document_list, ["dataset-query_001"])
# Use custom dataset name
results, dataset_ids = service.thalabus_query(
'MEDICAL_CONDITION AND "diabetes"',
create_dataset="medical_diabetes_cases",
session_id="user123-session456",
userid="user123"
)
# Returns: (document_list, ["dataset-medical_diabetes_cases"])
# No dataset creation (default behavior)
results, dataset_ids = service.thalabus_query('"cleanup with the mop"')
# Returns: (document_list, None)
# Restrict search to specific parent datasets (multi-parent OR logic)
results, dataset_ids = service.thalabus_query(
'"asylum seeker" AND COUNTRY=IQ',
ontology_id="Juris",
subset_of_datasetids=["dataset-legal-cases", "dataset-reference-docs"],
create_dataset="filtered_asylum_cases",
session_id="user123-session456"
)
# Returns: (document_list filtered to only docs in the parent datasets, ["dataset-filtered_asylum_cases"])
# The created dataset will be a child of both parent datasets
Dataset Structure
Created datasets include:
- Fragments: All fragment IDs from query results (can be Documents, Chapters, Paragraphs, or Summaries)
- d:Document nodes for document-level matches (e.g., category filters)
- c:Chapter nodes for chapter-level matches (fragmentid: "docid-c1", "docid-c2", ...)
- p:Paragraph nodes for paragraph-level matches (fragmentid: "docid-p1", "docid-p2", ...)
- s:Summary nodes for summary-level matches (fragmentid: "docid-s")
- Weights: Fragment scores (0.0-1.0, truncated to 5 decimals)
- Metadata:
- query: Original query string
- ontology_id: Ontology used in search
- item_count: Number of fragments in dataset
- fragment_types: Breakdown of fragment types (e.g., {"chapters": 50, "paragraphs": 120, "documents": 10})
- result_count: Number of aggregated document results from query
- creation: Set to "thalabus_query"
Fragment Types in Datasets
The type of nodes stored in the dataset depends on the query type. This table explains when each fragment type is passed to the dataset:
| Query Type | Fragment Types in Dataset | Reason |
|---|---|---|
| Category-only queries (e.g., CATEGORY=VALUE) |
d:Document nodes |
Categories are document-level metadata. The category filter returns entire documents. |
| Semantic search queries (e.g., "refugee crisis") |
c:Chapter, p:Paragraph, s:Summary nodes |
Semantic search uses vector similarity on chapter/paragraph/summary embeddings. Returns the specific fragments that match. |
| Text search queries (e.g., 'specific text') |
c:Chapter nodes (primarily) |
Fulltext index searches chapter text and keywords. Returns chapters containing the text. |
| Mixed queries (e.g., "asylum" AND COUNTRY=IQ) |
Mixed typesp:Paragraph + d:Document |
AND combines fragments from both branches. Left branch returns paragraphs, right branch returns documents. |
| OR queries (e.g., "text" OR CATEGORY) |
Mixed types Union of all fragment types |
OR unions all fragments from all branches. |
User presentation: While datasets store fragments, the thalabus_query() return value aggregates fragments to documents for user-friendly results.