Skip to main content

matrix_sdk/
message_search.rs

1// Copyright 2026 The Matrix.org Foundation C.I.C.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Messages search facilities and high-level helpers to perform searches across
16//! one or multiple rooms.
17//!
18//! These helpers expose the results as [`Stream`]s of pages, lazily fetching
19//! the next page from the underlying index as the stream is polled. Use the
20//! [`StreamExt`] and [`TryStreamExt`] combinators (`next`, `try_concat`,
21//! `take`, …) to consume them.
22//!
23//! [`StreamExt`]: futures_util::StreamExt
24//! [`TryStreamExt`]: futures_util::TryStreamExt
25//!
26//! # Examples
27//!
28//! ## Searching within a single room
29//!
30//! Use [`Room::search_messages`] to get a stream of pages of `(score,
31//! event_id)` pairs, or [`Room::search_messages_events`] to load the full
32//! [`TimelineEvent`]s.
33//!
34//! ```no_run
35//! # use matrix_sdk::Room;
36//! # use futures_util::StreamExt as _;
37//! # async fn example(room: Room) -> anyhow::Result<()> {
38//! let mut stream = Box::pin(room.search_messages("hello world".to_owned()));
39//!
40//! while let Some(page) = stream.next().await {
41//!     for (score, event_id) in page? {
42//!         println!("Found event {event_id} (score: {score})");
43//!     }
44//! }
45//! # Ok(())
46//! # }
47//! ```
48//!
49//! ## Searching across all joined rooms
50//!
51//! Use [`Client::search_messages`] to create a [`GlobalSearchBuilder`].
52//! Optionally restrict the working set to DM rooms (or non-DM rooms) before
53//! calling [`GlobalSearchBuilder::build`] to get a stream of pages of results,
54//! sorted by relevance score across all rooms. Use
55//! [`GlobalSearchBuilder::build_events`] to load full [`TimelineEvent`]s
56//! instead of plain event IDs.
57//!
58//! ```no_run
59//! # use matrix_sdk::Client;
60//! # use futures_util::StreamExt as _;
61//! # async fn example(client: Client) -> anyhow::Result<()> {
62//! // Search only in DM rooms.
63//! let mut stream = Box::pin(
64//!     client
65//!         .search_messages("hello world".to_owned())
66//!         .only_dm_rooms()
67//!         .await?
68//!         .build_events(),
69//! );
70//!
71//! while let Some(page) = stream.next().await {
72//!     for (room_id, event) in page? {
73//!         println!(
74//!             "Found event in room {room_id} with timestamp: {:?}",
75//!             event.timestamp
76//!         );
77//!     }
78//! }
79//! # Ok(())
80//! # }
81//! ```
82
83use std::{collections::HashSet, pin::Pin};
84
85use async_stream::try_stream;
86use futures_util::{Stream, StreamExt as _};
87use matrix_sdk_base::{RoomStateFilter, deserialized_responses::TimelineEvent};
88use matrix_sdk_search::error::IndexError;
89#[cfg(doc)]
90use matrix_sdk_search::index::RoomIndex;
91use ruma::{OwnedEventId, OwnedRoomId};
92
93use crate::{Client, Room};
94
95/// Number of results pulled from the index in one go while paginating through a
96/// search stream.
97const SEARCH_RESULTS_PAGE_SIZE: usize = 100;
98
99/// A boxed, score-descending stream of `(score, event_id)` results for a single
100/// room.
101type RoomResultStream = Pin<Box<dyn Stream<Item = Result<(f32, OwnedEventId), IndexError>> + Send>>;
102
103/// A cursor over one room's score-descending search results, used while merging
104/// results across rooms.
105struct RoomStreamCursor {
106    /// The room these results come from.
107    room_id: OwnedRoomId,
108
109    /// The room's score-descending result stream.
110    stream: RoomResultStream,
111
112    /// The next result this room would contribute to the merge: a one-item
113    /// lookahead buffered from `stream`, so we can compare every room's best
114    /// remaining result without consuming it. `None` once the stream is
115    /// exhausted.
116    next_result: Option<(f32, OwnedEventId)>,
117}
118
119impl Room {
120    /// Search this room's [`RoomIndex`] for query and return at most
121    /// max_number_of_results results.
122    pub async fn search(
123        &self,
124        query: &str,
125        max_number_of_results: usize,
126        pagination_offset: Option<usize>,
127    ) -> Result<Vec<(f32, OwnedEventId)>, IndexError> {
128        let mut search_index_guard = self.client.search_index().lock().await;
129        search_index_guard.search(query, max_number_of_results, pagination_offset, self.room_id())
130    }
131}
132
133/// An error that can occur while searching messages, using the high-level
134/// search helpers provided by this module.
135#[derive(thiserror::Error, Debug)]
136pub enum SearchError {
137    /// An error occurred while searching through the index for matching events.
138    #[error(transparent)]
139    IndexError(#[from] IndexError),
140    /// An error occurred while loading the event content for a search result.
141    #[error(transparent)]
142    EventLoadError(#[from] crate::Error),
143}
144
145impl Room {
146    /// Search for messages in this room matching the given query, returning a
147    /// stream of pages of `(score, event_id)` results sorted by descending
148    /// relevance score.
149    pub fn search_messages(
150        &self,
151        query: String,
152    ) -> impl Stream<Item = Result<Vec<(f32, OwnedEventId)>, IndexError>> + use<> {
153        let room = self.clone();
154
155        // TODO: use the client/server API search endpoint for public rooms, as those
156        // may require lots of time for indexing all events.
157        try_stream! {
158            let mut offset = 0;
159            loop {
160                let page = room.search(&query, SEARCH_RESULTS_PAGE_SIZE, Some(offset)).await?;
161                if page.is_empty() {
162                    break;
163                }
164                offset += page.len();
165                yield page;
166            }
167        }
168    }
169
170    /// Same as [`Room::search_messages`], but yields pages of full
171    /// [`TimelineEvent`]s instead of event IDs, by loading them from the store
172    /// or from the network.
173    pub fn search_messages_events(
174        &self,
175        query: String,
176    ) -> impl Stream<Item = Result<Vec<TimelineEvent>, SearchError>> + use<> {
177        let room = self.clone();
178
179        try_stream! {
180            let mut pages = Box::pin(room.search_messages(query));
181            while let Some(page) = pages.next().await {
182                let page = page?;
183                let mut events = Vec::with_capacity(page.len());
184                for (_score, event_id) in page {
185                    events.push(room.load_or_fetch_event(&event_id, None).await?);
186                }
187                yield events;
188            }
189        }
190    }
191}
192
193/// A builder for a global search [`Stream`] that allows configuring the initial
194/// working set of rooms to search in.
195#[derive(Debug)]
196pub struct GlobalSearchBuilder {
197    client: Client,
198
199    /// The search query, directly forwarded to the search API.
200    query: String,
201
202    /// The working set of rooms to search in.
203    room_set: Vec<Room>,
204}
205
206impl GlobalSearchBuilder {
207    /// Create a new global search on all the joined rooms.
208    fn new(client: Client, query: String) -> Self {
209        let room_set = client.rooms_filtered(RoomStateFilter::JOINED);
210        Self { client, query, room_set }
211    }
212
213    /// Keep only the DM rooms from the initial working set.
214    pub async fn only_dm_rooms(mut self) -> Result<Self, crate::Error> {
215        let mut to_remove = HashSet::new();
216        for room in &self.room_set {
217            if !room.compute_is_dm().await? {
218                to_remove.insert(room.room_id().to_owned());
219            }
220        }
221        self.room_set.retain(|room| !to_remove.contains(room.room_id()));
222        Ok(self)
223    }
224
225    /// Keep only non-DM rooms (groups) from the initial working set.
226    pub async fn no_dms(mut self) -> Result<Self, crate::Error> {
227        let mut to_remove = HashSet::new();
228        for room in &self.room_set {
229            if room.compute_is_dm().await? {
230                to_remove.insert(room.room_id().to_owned());
231            }
232        }
233        self.room_set.retain(|room| !to_remove.contains(room.room_id()));
234        Ok(self)
235    }
236
237    /// Build a stream over the search results across all the rooms in the
238    /// working set, yielding pages of `(room_id, score, event_id)` tuples
239    /// sorted by descending relevance score.
240    pub fn build(
241        self,
242    ) -> impl Stream<Item = Result<Vec<(OwnedRoomId, f32, OwnedEventId)>, IndexError>> {
243        let query = self.query;
244        let rooms = self.room_set;
245
246        try_stream! {
247            // One score-descending result stream per room, each primed with its next
248            // result so we can merge across rooms by score.
249            let mut cursors: Vec<RoomStreamCursor> = Vec::with_capacity(rooms.len());
250            for room in rooms {
251                let room_id = room.room_id().to_owned();
252                let stream = Box::pin(Self::flatten_pages(room.search_messages(query.clone())));
253                cursors.push(RoomStreamCursor { room_id, stream, next_result: None });
254            }
255            for cursor in &mut cursors {
256                cursor.next_result = match cursor.stream.next().await {
257                    Some(result) => Some(result?),
258                    None => None,
259                };
260            }
261
262            let mut page = Vec::with_capacity(SEARCH_RESULTS_PAGE_SIZE);
263            loop {
264                // Pick the room whose next result has the highest relevance score.
265                let best = cursors
266                    .iter()
267                    .enumerate()
268                    .filter_map(|(index, cursor)| {
269                        cursor.next_result.as_ref().map(|(score, _)| (index, *score))
270                    })
271                    .max_by(|(_, a), (_, b)| a.total_cmp(b));
272
273                let Some((index, _)) = best else {
274                    // Every room is exhausted.
275                    break;
276                };
277
278                let cursor = &mut cursors[index];
279                let (score, event_id) =
280                    cursor.next_result.take().expect("the chosen room must have a next result");
281                let room_id = cursor.room_id.clone();
282
283                // Refill this room's lookahead for the next iteration.
284                cursor.next_result = match cursor.stream.next().await {
285                    Some(result) => Some(result?),
286                    None => None,
287                };
288
289                page.push((room_id, score, event_id));
290                if page.len() == SEARCH_RESULTS_PAGE_SIZE {
291                    yield std::mem::take(&mut page);
292                }
293            }
294
295            if !page.is_empty() {
296                yield page;
297            }
298        }
299    }
300
301    /// Same as [`Self::build`], but yields pages of full [`TimelineEvent`]s
302    /// instead of event IDs, by loading them from the store or from the
303    /// network.
304    pub fn build_events(
305        self,
306    ) -> impl Stream<Item = Result<Vec<(OwnedRoomId, TimelineEvent)>, SearchError>> {
307        let client = self.client.clone();
308        let pages = self.build();
309
310        try_stream! {
311            let mut pages = Box::pin(pages);
312            while let Some(page) = pages.next().await {
313                let page = page?;
314                let mut events = Vec::with_capacity(page.len());
315                for (room_id, _score, event_id) in page {
316                    let Some(room) = client.get_room(&room_id) else {
317                        continue;
318                    };
319                    events.push((room_id, room.load_or_fetch_event(&event_id, None).await?));
320                }
321                yield events;
322            }
323        }
324    }
325
326    /// Flatten a stream of result pages into a stream of individual results, so
327    /// the cross-room merge can compare results one at a time.
328    fn flatten_pages(
329        pages: impl Stream<Item = Result<Vec<(f32, OwnedEventId)>, IndexError>>,
330    ) -> impl Stream<Item = Result<(f32, OwnedEventId), IndexError>> {
331        try_stream! {
332            let mut pages = Box::pin(pages);
333            while let Some(page) = pages.next().await {
334                for result in page? {
335                    yield result;
336                }
337            }
338        }
339    }
340}
341
342impl Client {
343    /// Search across all rooms for events with the given query, returning a
344    /// builder for a stream over the results.
345    pub fn search_messages(&self, query: String) -> GlobalSearchBuilder {
346        GlobalSearchBuilder::new(self.clone(), query)
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use std::time::Duration;
353
354    use futures_util::TryStreamExt as _;
355    use matrix_sdk_test::{BOB, JoinedRoomBuilder, async_test, event_factory::EventFactory};
356    use ruma::{OwnedEventId, OwnedRoomId, event_id, room_id, user_id};
357
358    use crate::{sleep::sleep, test_utils::mocks::MatrixMockServer};
359
360    #[async_test]
361    async fn test_room_message_search() {
362        let server = MatrixMockServer::new().await;
363        let client = server.client_builder().build().await;
364
365        let event_cache = client.event_cache();
366        event_cache.subscribe().unwrap();
367
368        let room_id = room_id!("!room_id:localhost");
369        let room = server.sync_joined_room(&client, room_id).await;
370
371        let f = EventFactory::new().room(room_id).sender(user_id!("@user_id:localhost"));
372
373        let event_id = event_id!("$event_id:localhost");
374
375        server
376            .sync_room(
377                &client,
378                JoinedRoomBuilder::new(room_id)
379                    .add_timeline_event(f.text_msg("hello world").event_id(event_id)),
380            )
381            .await;
382
383        // Let the search indexer process the new event.
384        sleep(Duration::from_millis(200)).await;
385
386        // Searching for a missing keyword should succeed and yield nothing.
387        {
388            let results: Vec<(f32, OwnedEventId)> =
389                room.search_messages("search query".to_owned()).try_concat().await.unwrap();
390            assert!(results.is_empty());
391        }
392
393        // Search for an existing keyword, by event id.
394        {
395            let results: Vec<(f32, OwnedEventId)> =
396                room.search_messages("world".to_owned()).try_concat().await.unwrap();
397            assert_eq!(results.len(), 1);
398            assert_eq!(results[0].1, event_id);
399        }
400
401        // Search for an existing keyword, by events.
402        {
403            let events: Vec<_> =
404                room.search_messages_events("world".to_owned()).try_concat().await.unwrap();
405            assert_eq!(events.len(), 1);
406            assert_eq!(events[0].event_id().unwrap(), event_id);
407        }
408    }
409
410    #[async_test]
411    async fn test_global_message_search() {
412        let server = MatrixMockServer::new().await;
413        let client = server.client_builder().build().await;
414
415        let event_cache = client.event_cache();
416        event_cache.subscribe().unwrap();
417
418        let room_id1 = room_id!("!r1:localhost");
419        let room_id2 = room_id!("!r2:localhost");
420
421        let f = EventFactory::new().sender(user_id!("@user_id:localhost"));
422
423        let result_event_id1 = event_id!("$result1:localhost");
424        let result_event_id2 = event_id!("$result2:localhost");
425
426        server
427            .mock_sync()
428            .ok_and_run(&client, |sync_builder| {
429                sync_builder
430                    .add_joined_room(
431                        JoinedRoomBuilder::new(room_id1)
432                            .add_timeline_event(
433                                f.text_msg("hello world").room(room_id1).event_id(result_event_id1),
434                            )
435                            .add_timeline_event(f.text_msg("hello back").room(room_id1)),
436                    )
437                    .add_joined_room(JoinedRoomBuilder::new(room_id2).add_timeline_event(
438                        f.text_msg("it's a mad world").room(room_id2).event_id(result_event_id2),
439                    ));
440            })
441            .await;
442
443        // Let the search indexer process the new event.
444        sleep(Duration::from_millis(200)).await;
445
446        // Searching for a missing keyword should succeed and yield nothing.
447        {
448            let results: Vec<(OwnedRoomId, f32, OwnedEventId)> = client
449                .search_messages("search query".to_owned())
450                .build()
451                .try_concat()
452                .await
453                .unwrap();
454            assert!(results.is_empty());
455        }
456
457        // Search for an existing keyword, by event id.
458        {
459            let results: Vec<(OwnedRoomId, f32, OwnedEventId)> =
460                client.search_messages("world".to_owned()).build().try_concat().await.unwrap();
461            assert_eq!(results.len(), 2);
462            // Search results order is not guaranteed, so we check that both expected
463            // results are present.
464            assert!(results.iter().any(|(room_id, _, event_id)| {
465                room_id == room_id1 && event_id == result_event_id1
466            }));
467            assert!(results.iter().any(|(room_id, _, event_id)| {
468                room_id == room_id2 && event_id == result_event_id2
469            }));
470        }
471
472        // Search for an existing keyword, by event.
473        {
474            let results: Vec<_> = client
475                .search_messages("world".to_owned())
476                .build_events()
477                .try_concat()
478                .await
479                .unwrap();
480            assert_eq!(results.len(), 2);
481            // Search results order is not guaranteed, so we check that both expected
482            // results are present.
483            assert!(results.iter().any(|(room_id, event)| {
484                room_id == room_id1 && event.event_id() == Some(result_event_id1)
485            }));
486            assert!(results.iter().any(|(room_id, event)| {
487                room_id == room_id2 && event.event_id() == Some(result_event_id2)
488            }));
489        }
490    }
491
492    #[async_test]
493    async fn test_global_message_search_score_ordering() {
494        let server = MatrixMockServer::new().await;
495        let client = server.client_builder().build().await;
496
497        let event_cache = client.event_cache();
498        event_cache.subscribe().unwrap();
499
500        let room_id1 = room_id!("!r1:localhost");
501        let room_id2 = room_id!("!r2:localhost");
502
503        let f = EventFactory::new().sender(user_id!("@user_id:localhost"));
504
505        // Both rooms get two documents of identical length (padded with filler so
506        // document-length normalization and the per-corpus IDF of "world" match across
507        // rooms). The score then depends only on how many times "world" appears.
508        //
509        // Term frequencies are 4, 3, 2, 1, split so the rooms alternate by rank:
510        // room1 holds the 4x and 2x events, room2 the 3x and 1x events. A correct
511        // cross-room sort therefore interleaves the rooms: r1, r2, r1, r2.
512        let r1_rank1 = event_id!("$r1_rank1:localhost"); // room1, "world" x4
513        let r2_rank2 = event_id!("$r2_rank2:localhost"); // room2, "world" x3
514        let r1_rank3 = event_id!("$r1_rank3:localhost"); // room1, "world" x2
515        let r2_rank4 = event_id!("$r2_rank4:localhost"); // room2, "world" x1
516
517        server
518            .mock_sync()
519            .ok_and_run(&client, |sync_builder| {
520                sync_builder
521                    .add_joined_room(
522                        JoinedRoomBuilder::new(room_id1)
523                            .add_timeline_event(
524                                f.text_msg("world world world world filler filler filler filler filler filler")
525                                    .room(room_id1)
526                                    .event_id(r1_rank1),
527                            )
528                            .add_timeline_event(
529                                f.text_msg("world world filler filler filler filler filler filler filler filler")
530                                    .room(room_id1)
531                                    .event_id(r1_rank3),
532                            ),
533                    )
534                    .add_joined_room(
535                        JoinedRoomBuilder::new(room_id2)
536                            .add_timeline_event(
537                                f.text_msg("world world world filler filler filler filler filler filler filler")
538                                    .room(room_id2)
539                                    .event_id(r2_rank2),
540                            )
541                            .add_timeline_event(
542                                f.text_msg("world filler filler filler filler filler filler filler filler filler")
543                                    .room(room_id2)
544                                    .event_id(r2_rank4),
545                            ),
546                    );
547            })
548            .await;
549
550        sleep(Duration::from_millis(200)).await;
551
552        let results: Vec<(OwnedRoomId, f32, OwnedEventId)> =
553            client.search_messages("world".to_owned()).build().try_concat().await.unwrap();
554        assert_eq!(results.len(), 4);
555
556        // Results are interleaved across the two rooms strictly by score.
557        assert_eq!((&results[0].0, &results[0].2), (&room_id1.to_owned(), &r1_rank1.to_owned()));
558        assert_eq!((&results[1].0, &results[1].2), (&room_id2.to_owned(), &r2_rank2.to_owned()));
559        assert_eq!((&results[2].0, &results[2].2), (&room_id1.to_owned(), &r1_rank3.to_owned()));
560        assert_eq!((&results[3].0, &results[3].2), (&room_id2.to_owned(), &r2_rank4.to_owned()));
561    }
562
563    #[async_test]
564    async fn test_global_message_search_dm_or_groups() {
565        let server = MatrixMockServer::new().await;
566        let client = server.client_builder().build().await;
567
568        let event_cache = client.event_cache();
569        event_cache.subscribe().unwrap();
570
571        // This time, room_id1 is a DM room,
572        let room_id1 = room_id!("!r1:localhost");
573        // While room_id2 isn't.
574        let room_id2 = room_id!("!r2:localhost");
575
576        let f = EventFactory::new().sender(user_id!("@user_id:localhost"));
577
578        let result_event_id1 = event_id!("$result1:localhost");
579        let result_event_id2 = event_id!("$result2:localhost");
580
581        server
582            .mock_sync()
583            .ok_and_run(&client, |sync_builder| {
584                sync_builder
585                    .add_joined_room(
586                        JoinedRoomBuilder::new(room_id1)
587                            .add_timeline_event(
588                                f.text_msg("hello world").room(room_id1).event_id(result_event_id1),
589                            )
590                            .add_timeline_event(f.text_msg("hello back").room(room_id1)),
591                    )
592                    .add_joined_room(JoinedRoomBuilder::new(room_id2).add_timeline_event(
593                        f.text_msg("it's a mad world").room(room_id2).event_id(result_event_id2),
594                    ))
595                    // Note: adding a DM room for room_id1 here.
596                    .add_global_account_data(
597                        f.direct().add_user((*BOB).to_owned().into(), room_id1),
598                    );
599            })
600            .await;
601
602        // Let the search indexer process the new event.
603        sleep(Duration::from_millis(200)).await;
604
605        // Search for an existing keyword, by event id, only in DMs.
606        {
607            let results: Vec<(OwnedRoomId, f32, OwnedEventId)> = client
608                .search_messages("world".to_owned())
609                .only_dm_rooms()
610                .await
611                .unwrap()
612                .build()
613                .try_concat()
614                .await
615                .unwrap();
616
617            assert_eq!(results.len(), 1);
618            assert_eq!(
619                (&results[0].0, &results[0].2),
620                (&room_id1.to_owned(), &result_event_id1.to_owned())
621            );
622        }
623
624        // Search for an existing keyword, by event, only in groups.
625        {
626            let results: Vec<_> = client
627                .search_messages("world".to_owned())
628                .no_dms()
629                .await
630                .unwrap()
631                .build_events()
632                .try_concat()
633                .await
634                .unwrap();
635
636            assert_eq!(results.len(), 1);
637            assert_eq!(results[0].0, room_id2);
638            assert_eq!(results[0].1.event_id().unwrap(), result_event_id2);
639        }
640    }
641}