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

> Discover how Twenty CRM implements advanced search using PostgreSQL tsvector full-text indexing. Learn about ILIKE fallbacks, result ranking, and GraphQL pagination for efficient data retrieval.

- Repository: [Twenty/twenty](https://github.com/twentyhq/twenty)
- Tags: internals
- Published: 2026-03-27

---

**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.

```typescript
// 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`](https://github.com/twentyhq/twenty/blob/main/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

```typescript
// 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`](https://github.com/twentyhq/twenty/blob/main/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`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-server/src/engine/core-modules/search/utils/format-search-terms.ts) to transform user input into PostgreSQL-compatible search strings:

```typescript
// 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`](https://github.com/twentyhq/twenty/blob/main/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:

```sql
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:

```typescript
{
  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:

```typescript
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`](https://github.com/twentyhq/twenty/blob/main/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.