How Search Works in Twenty CRM: Full-Text Implementation with PostgreSQL

Twenty CRM implements global search functionality using PostgreSQL tsvector full-text indexing combined with an ILIKE fallback mechanism, ranking results by coverage-density algorithms and delivering them through cursor-based GraphQL pagination.

Twenty CRM is an open-source customer relationship management platform that provides powerful search capabilities across all workspace objects. The search functionality in Twenty CRM leverages PostgreSQL's native full-text search capabilities combined with intelligent fallback mechanisms to deliver fast, relevant results. This implementation spans both the server-side GraphQL API and the React-based frontend, using a sophisticated pipeline that ranks results by relevance and supports efficient pagination for large datasets.

Architecture Overview

The search implementation follows a layered pipeline architecture. The frontend issues a GraphQL query that travels through Apollo Client to the NestJS GraphQL Server, where the SearchResolver authenticates the request and loads workspace metadata. The resolver delegates execution to SearchService, which constructs optimized SQL queries using PostgreSQL's tsvector capabilities. If the indexed search returns no results for the first page, the system automatically falls back to an ILIKE pattern match to handle tokenization edge cases such as CJK text. Results are ranked using ts_rank_cd and ts_rank, sorted by object priority, and returned as a cursor-paginated connection.

GraphQL API Entry Point

All search requests enter through a single GraphQL query defined in the frontend package. The query accepts parameters for search input, pagination cursors, and object filtering.

// packages/twenty-front/src/modules/command-menu/graphql/queries/search.ts
query Search(
  $searchInput: String!
  $limit: Int!
  $after: String
  $excludedObjectNameSingulars: [String!]
  $includedObjectNameSingulars: [String!]
  $filter: ObjectRecordFilterInput
) {
  search(
    searchInput: $searchInput
    limit: $limit
    after: $after
    excludedObjectNameSingulars: $excludedObjectNameSingulars
    includedObjectNameSingulars: $includedObjectNameSingulars
    filter: $filter
  ) {
    edges {
      node {
        recordId
        objectNameSingular
        label
        imageUrl
      }
      cursor
    }
    pageInfo {
      endCursor
      hasNextPage
    }
  }
}

UI components including the command menu, global search bar, and record pickers consume this query via Apollo Client or any compatible GraphQL client.

Backend Implementation

SearchResolver Orchestration

The SearchResolver located in packages/twenty-server/src/engine/core-modules/search/search.resolver.ts serves as the entry point for all search operations. It performs three critical functions before delegating to the service layer:

  1. Authentication and Workspace Resolution – Validates the user's session and identifies the target workspace
  2. Metadata Loading – Retrieves flattened entity maps containing all object and field metadata for the workspace using workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps
  3. Object Filtering – Applies inclusion and exclusion lists to determine which object types should participate in the search
// From search.resolver.ts
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
  await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps({
    workspaceId: workspace.id,
    flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
  });

const filteredObjectMetadataItems = this.searchService.filterObjectMetadataItems({
  flatObjectMetadatas,
  includedObjectNameSingulars: includedObjectNameSingulars ?? [],
  excludedObjectNameSingulars: excludedObjectNameSingulars ?? [],
});

SearchService Core Logic

SearchService in packages/twenty-server/src/engine/core-modules/search/services/search.service.ts contains the core search engine implementation. The service processes requests through several specialized methods:

Object-Level Filtering – The filterObjectMetadataItems method removes objects where isSearchable is false or those matching excluded patterns. Channel-visibility objects (such as messaging channels) are automatically excluded from search results regardless of configuration.

Chunked Processing – To prevent database overload, the service processes searchable objects in chunks of five (OBJECT_METADATA_ITEMS_CHUNK_SIZE = 5), executing parallel queries across object types while respecting connection limits.

Search Query Construction – Two distinct query builders handle different search strategies:

  • buildSearchQueryAndGetRecords – Constructs tsvector queries using to_tsquery('simple', unaccent(:searchTerms)) against the searchVector column
  • buildIlikeFallbackQuery – Executes ILIKE patterns against raw text when tsvector tokenization fails to match (essential for CJK characters and special symbols)

Both methods utilize formatSearchTerms from packages/twenty-server/src/engine/core-modules/search/utils/format-search-terms.ts to transform user input into PostgreSQL-compatible search strings:

// Input: "john doe" → Output: "john:* & doe:*"
formatSearchTerms('john doe', 'and'); // Used for tsquery AND logic
formatSearchTerms('john doe', 'or');  // Used for tsquery OR logic

PostgreSQL Full-Text Search Strategy

The primary search mechanism relies on PostgreSQL's tsvector full-text indexing. Every searchable object maintains a searchVector column (defined in packages/twenty-server/src/engine/metadata-modules/search-field-metadata/constants/search-vector-field.constants.ts) that stores pre-computed lexemes from searchable fields.

The generated SQL queries select ranking columns using PostgreSQL's built-in algorithms:

SELECT 
  ts_rank_cd(searchVector, to_tsquery('simple', unaccent(:searchTerms))) AS tsRankCD,
  ts_rank(searchVector, to_tsquery('simple', unaccent(:searchTermsOr))) AS tsRank
FROM object_records
WHERE searchVector @@ to_tsquery('simple', unaccent(:searchTerms))

Coverage-Density Ranking – ts_rank_cd prioritizes results where search terms appear closer together and cover larger portions of the document. Standard Ranking – ts_rank provides a secondary sort based on term frequency. When the tsvector query returns zero rows for the first page, the system automatically triggers the ILIKE fallback to ensure no relevant results are missed due to tokenization differences.

Ranking and Sorting

After retrieving records, sortSearchObjectResults applies a multi-tier sorting algorithm:

  1. Standard Object Priority – Objects are ranked according to STANDARD_OBJECTS_BY_PRIORITY_RANK (e.g., People and Companies surface before custom objects)
  2. Coverage-Density Score – Descending tsRankCD values
  3. Standard Rank Score – Descending tsRank values
  4. Record ID – Ascending id for deterministic ordering

This hierarchy ensures that high-priority business objects appear first even if their raw text match scores are slightly lower than secondary objects.

Cursor-Based Pagination

Twenty CRM implements cursor-based pagination rather than offset-based paging to maintain performance with large datasets. The cursor encodes the ranking state and last seen record IDs:

{
  lastRanks: { tsRankCD: number, tsRank: number },
  lastRecordIdsPerObject: { [objectNameSingular]: string }
}

The encodeCursorData function serializes this state into the cursor field of each GraphQL edge. During subsequent requests, decodeCursor and computeCursorWhereCondition reconstruct the pagination state to fetch the next page efficiently without recalculating offsets across the entire result set.

Result Enrichment

computeSearchObjectResults assembles the final SearchResultConnectionDTO by enriching raw database records with presentation metadata:

  • Labels – Extracted from the object's label identifier field (e.g., concatenating firstName and lastName for Person records, or using companyName for Company records via getLabelIdentifierColumns)
  • Images – Resolved through getImageIdentifierValue, which may sign secure file URLs or fetch domain logos using getLogoUrlFromDomainName

Frontend Integration

Client applications consume the search API using Apollo Client's useQuery hook. The implementation supports real-time loading states and infinite scroll pagination:

import { useQuery } from '@apollo/client';
import { SEARCH_QUERY } from '@/modules/command-menu/graphql/queries/search';

const { data, loading, fetchMore } = useQuery(SEARCH_QUERY, {
  variables: {
    searchInput: 'john doe',
    limit: 20,
    after: null,
    excludedObjectNameSingulars: [],
    includedObjectNameSingulars: [],
    filter: {},
  },
});

const results = data?.search?.edges?.map((e) => e.node) ?? [];

// Load next page using cursor
const loadMore = () => {
  if (data?.search?.pageInfo?.hasNextPage) {
    fetchMore({
      variables: {
        after: data.search.pageInfo.endCursor,
      },
    });
  }
};

The query definition resides in packages/twenty-front/src/modules/command-menu/graphql/queries/search.ts and is shared across the command menu, global search bar, record pickers, and workflow variable selectors.

Summary

  • Twenty CRM implements global search using a GraphQL endpoint backed by PostgreSQL tsvector full-text indexing
  • The SearchResolver handles authentication and metadata loading before delegating to SearchService
  • SearchService filters searchable objects, processes them in chunks of 5, and executes tsvector queries with an ILIKE fallback for edge cases
  • Results are ranked using ts_rank_cd (coverage-density) and ts_rank, then sorted by standard object priority
  • Cursor-based pagination encodes ranking state and record IDs for efficient paging without offsets
  • The frontend consumes the API via Apollo Client using the SEARCH_QUERY GraphQL operation

Frequently Asked Questions

How does Twenty CRM handle search queries in non-Latin languages like Chinese or Japanese?

Twenty CRM addresses CJK (Chinese, Japanese, Korean) tokenization limitations through an ILIKE fallback mechanism. When the primary tsvector query returns zero rows for the first page of results, SearchService automatically executes buildIlikeFallbackQuery, which performs pattern matching against the raw searchVector text. This ensures that searches containing characters not properly tokenized by PostgreSQL's simple text search dictionary still return relevant matches.

Can developers customize which objects appear in search results?

Yes. The search system respects the isSearchable boolean flag defined in object metadata. Administrators can exclude specific object types by providing the excludedObjectNameSingulars array in the GraphQL query, or restrict results to specific objects using includedObjectNameSingulars. Additionally, the system automatically excludes channel-visibility objects (such as private messaging channels) from all search results regardless of these parameters.

Why does Twenty CRM use cursor-based pagination instead of offset-based paging?

Cursor-based pagination provides deterministic performance when sorting by dynamic rank scores. Because results are ordered by ts_rank_cd and ts_rank (which vary based on search terms), offset-based paging would require re-calculating ranks for all preceding records on each page. The cursor encodes the last seen tsRankCD, tsRank, and record IDs, allowing the database to seek directly to the next result set using indexed comparisons rather than scanning and skipping rows.

How are search results ranked when multiple object types match the same query?

Results undergo multi-tier sorting implemented in sortSearchObjectResults. First, objects are sorted according to STANDARD_OBJECTS_BY_PRIORITY_RANK, ensuring core CRM objects (People, Companies) surface before custom objects. Within each object type, records are ordered by tsRankCD (coverage-density) descending, then tsRank descending, and finally by id ascending. This prioritizes records where search terms appear in close proximity and cover significant portions of the searchable text.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →