diff --git a/src/search/dto/search-query.dto.ts b/src/search/dto/search-query.dto.ts
new file mode 100644
index 00000000..a1d06148
--- /dev/null
+++ b/src/search/dto/search-query.dto.ts
@@ -0,0 +1,66 @@
+import {
+ IsNotEmpty,
+ IsOptional,
+ IsString,
+ IsEnum,
+ IsInt,
+ Min,
+ Max,
+ MaxLength,
+} from "class-validator";
+import { Type } from "class-transformer";
+import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
+
+export enum SearchResourceType {
+ USER = "user",
+ MESSAGE = "message",
+ CONVERSATION = "conversation",
+ ALL = "all",
+}
+
+export class SearchQueryDto {
+ @ApiProperty({
+ description: "Full-text search query string",
+ example: "alice blockchain",
+ maxLength: 256,
+ })
+ @IsNotEmpty()
+ @IsString()
+ @MaxLength(256)
+ q: string;
+
+ @ApiPropertyOptional({
+ description: "Resource type to search within",
+ enum: SearchResourceType,
+ default: SearchResourceType.ALL,
+ })
+ @IsOptional()
+ @IsEnum(SearchResourceType)
+ type?: SearchResourceType = SearchResourceType.ALL;
+
+ @ApiPropertyOptional({
+ description: "Page number (1-based)",
+ example: 1,
+ minimum: 1,
+ default: 1,
+ })
+ @IsOptional()
+ @Type(() => Number)
+ @IsInt()
+ @Min(1)
+ page?: number = 1;
+
+ @ApiPropertyOptional({
+ description: "Results per page (max 50)",
+ example: 20,
+ minimum: 1,
+ maximum: 50,
+ default: 20,
+ })
+ @IsOptional()
+ @Type(() => Number)
+ @IsInt()
+ @Min(1)
+ @Max(50)
+ limit?: number = 20;
+}
diff --git a/src/search/dto/search-result.dto.ts b/src/search/dto/search-result.dto.ts
new file mode 100644
index 00000000..a01682ff
--- /dev/null
+++ b/src/search/dto/search-result.dto.ts
@@ -0,0 +1,53 @@
+import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
+
+export class SearchHitDto {
+ @ApiProperty({ description: "Unique resource identifier" })
+ id: string;
+
+ @ApiProperty({
+ description: "Resource type",
+ enum: ["user", "message", "conversation"],
+ })
+ type: "user" | "message" | "conversation";
+
+ @ApiProperty({ description: "Primary display text for the result" })
+ title: string;
+
+ @ApiPropertyOptional({
+ description:
+ "Short highlighted snippet showing where the query matched. Tags and wrap the matching term.",
+ })
+ snippet?: string;
+
+ @ApiProperty({ description: "Full-text relevance score" })
+ score: number;
+
+ @ApiProperty({ description: "Original resource data" })
+ data: Record;
+
+ @ApiProperty({ description: "When the resource was last modified" })
+ updatedAt: Date;
+}
+
+export class PaginatedSearchResultDto {
+ @ApiProperty({ type: [SearchHitDto] })
+ data: SearchHitDto[];
+
+ @ApiProperty({ description: "Total number of matching records" })
+ total: number;
+
+ @ApiProperty({ description: "Current page number" })
+ page: number;
+
+ @ApiProperty({ description: "Records per page" })
+ limit: number;
+
+ @ApiProperty({ description: "Total pages" })
+ totalPages: number;
+
+ @ApiProperty({ description: "Query that produced this result" })
+ query: string;
+
+ @ApiProperty({ description: "Resource type filter applied" })
+ resourceType: string;
+}
diff --git a/src/search/entities/search-index.entity.ts b/src/search/entities/search-index.entity.ts
new file mode 100644
index 00000000..b292d24e
--- /dev/null
+++ b/src/search/entities/search-index.entity.ts
@@ -0,0 +1,65 @@
+import {
+ Entity,
+ PrimaryGeneratedColumn,
+ Column,
+ Index,
+ CreateDateColumn,
+ UpdateDateColumn,
+} from "typeorm";
+
+/**
+ * Flat denormalized table that mirrors searchable content from all resources.
+ *
+ * The `tsv` column holds a pre-computed PostgreSQL tsvector so that search
+ * queries are a simple index scan instead of an on-the-fly conversion.
+ *
+ * A GIN index is created on `tsv` via a raw migration / synchronize, making
+ * full-text search fast even with millions of rows.
+ */
+@Entity("search_index")
+@Index("IDX_SEARCH_RESOURCE", ["resourceType", "resourceId"], { unique: true })
+export class SearchIndex {
+ @PrimaryGeneratedColumn("uuid")
+ id: string;
+
+ /**
+ * Discriminator for the source table (user | message | conversation).
+ */
+ @Column({ type: "varchar", length: 32 })
+ @Index("IDX_SEARCH_RESOURCE_TYPE")
+ resourceType: "user" | "message" | "conversation";
+
+ /**
+ * PK of the originating row in its own table.
+ */
+ @Column({ type: "uuid" })
+ resourceId: string;
+
+ /**
+ * Concatenated plain text used for pg full-text search.
+ * Stored explicitly so we can rebuild tsv without re-reading source tables.
+ */
+ @Column({ type: "text" })
+ plainText: string;
+
+ /**
+ * Serialized tsvector kept in sync by a trigger *or* by the indexer service.
+ * Stored as text because TypeORM does not natively map `tsvector`.
+ * We cast it at query time: `to_tsvector('english', plain_text)`.
+ */
+ @Column({ type: "text", nullable: true })
+ tsv: string | null;
+
+ /**
+ * Arbitrary JSON bag – title, username, avatarUrl, etc.
+ * Returned alongside the search hit for rendering without a secondary query.
+ */
+ @Column({ type: "jsonb", default: {} })
+ metadata: Record;
+
+ @CreateDateColumn()
+ createdAt: Date;
+
+ @UpdateDateColumn()
+ updatedAt: Date;
+}
diff --git a/src/search/interfaces/search-indexer.interface.ts b/src/search/interfaces/search-indexer.interface.ts
new file mode 100644
index 00000000..5750541e
--- /dev/null
+++ b/src/search/interfaces/search-indexer.interface.ts
@@ -0,0 +1,11 @@
+/**
+ * Represents one document in the search index table.
+ */
+export interface SearchIndexDocument {
+ resourceType: "user" | "message" | "conversation";
+ resourceId: string;
+ /** Plain text that will be stored for snippet generation */
+ plainText: string;
+ /** Extra metadata stored as JSON (title, sender, etc.) */
+ metadata: Record;
+}