diff --git a/changelog/unreleased/enhancement-search-term-linking b/changelog/unreleased/enhancement-search-term-linking
new file mode 100644
index 00000000000..0dd8771ba76
--- /dev/null
+++ b/changelog/unreleased/enhancement-search-term-linking
@@ -0,0 +1,16 @@
+Enhancement: Search query term linking
+
+We've added the option to search for multiple terms with the same type,
+at the moment only the tag search benefits from it.
+
+This makes it possible to search for multiple resources with different tags in one query.
+The UI now empowers the user to perform advanced searches like:
+
+* all resources with the tags `tag1` OR `tag2`
+* all resources with the tags `tag1` OR `tag2` AND containing text `content`
+
+as a rule of thumb, if a property appears multiple times (like `tag1` OR `tag2`)
+the search combines the query with an `OR` and different keys are linked with an `AND`.
+
+https://github.com/owncloud/web/pull/9854
+https://github.com/owncloud/web/issues/9829
diff --git a/packages/web-app-files/src/components/Search/List.vue b/packages/web-app-files/src/components/Search/List.vue
index ec489f80daa..e5d584c6015 100644
--- a/packages/web-app-files/src/components/Search/List.vue
+++ b/packages/web-app-files/src/components/Search/List.vue
@@ -10,12 +10,12 @@
         <item-filter
           v-if="availableTags.length"
           ref="tagFilter"
+          :allow-multiple="true"
           :filter-label="$gettext('Tags')"
           :filterable-attributes="['label']"
           :items="availableTags"
           :option-filter-label="$gettext('Filter tags')"
           :show-option-filter="true"
-          :close-on-click="true"
           class="files-search-filter-tags oc-mr-s"
           display-name-attribute="label"
           filter-name="tags"
@@ -136,7 +136,17 @@ import { debounce } from 'lodash-es'
 import { mapMutations, mapGetters, mapActions } from 'vuex'
 import { useGettext } from 'vue3-gettext'
 import { AppBar } from '@ownclouders/web-pkg'
-import { computed, defineComponent, nextTick, onMounted, ref, unref, VNodeRef, watch } from 'vue'
+import {
+  computed,
+  defineComponent,
+  nextTick,
+  onMounted,
+  Ref,
+  ref,
+  unref,
+  VNodeRef,
+  watch
+} from 'vue'
 import ListInfo from '../FilesList/ListInfo.vue'
 import { Pagination } from '@ownclouders/web-pkg'
 import { useFileActions } from '@ownclouders/web-pkg'
@@ -278,57 +288,71 @@ export default defineComponent({
     )
 
     const buildSearchTerm = (manuallyUpdateFilterChip = false) => {
-      let query = ''
-      const add = (k: string, v: string) => {
-        query = (query + ` ${k}:${v}`).trimStart()
-      }
+      const query = {}
 
       const humanSearchTerm = unref(searchTerm)
       const isContentOnlySearch = queryItemAsString(unref(fullTextParam)) == 'true'
 
       if (isContentOnlySearch && !!humanSearchTerm) {
-        add('content', `"${humanSearchTerm}"`)
+        query['content'] = `"${humanSearchTerm}"`
       } else if (!!humanSearchTerm) {
-        add('name', `"*${humanSearchTerm}*"`)
+        query['name'] = `"*${humanSearchTerm}*"`
       }
 
       const humanScopeQuery = unref(scopeQuery)
       const isScopedSearch = unref(doUseScope) === 'true'
       if (isScopedSearch && humanScopeQuery) {
-        add('scope', `${humanScopeQuery}`)
+        query['scope'] = `${humanScopeQuery}`
       }
 
-      const humanTagsParams = queryItemAsString(unref(tagParam))
-      if (humanTagsParams) {
-        add('tag', `"${humanTagsParams}"`)
-
-        if (manuallyUpdateFilterChip && unref(tagFilter)) {
+      const updateFilter = (v: Ref) => {
+        if (manuallyUpdateFilterChip && unref(v)) {
           /**
            * Handles edge cases where a filter is not being applied via the filter directly,
            * e.g. when clicking on a tag in the files list.
            * We need to manually update the selected items in the ItemFilter component because normally
            * it only does this on mount or when interacting with the filter directly.
            */
-          ;(unref(tagFilter) as any).setSelectedItemsBasedOnQuery()
+          ;(unref(v) as any).setSelectedItemsBasedOnQuery()
         }
       }
 
+      const humanTagsParams = queryItemAsString(unref(tagParam))
+      if (humanTagsParams) {
+        query['tag'] = humanTagsParams.split('+').map((t) => `"${t}"`)
+        updateFilter(tagFilter)
+      }
+
       const lastModifiedParams = queryItemAsString(unref(lastModifiedParam))
       if (lastModifiedParams) {
-        add('mtime', `"${lastModifiedParams}"`)
-
-        if (manuallyUpdateFilterChip && unref(lastModifiedFilter)) {
-          /**
-           * Handles edge cases where a filter is not being applied via the filter directly,
-           * e.g. when clicking on a tag in the files list.
-           * We need to manually update the selected items in the ItemFilter component because normally
-           * it only does this on mount or when interacting with the filter directly.
-           */
-          ;(unref(lastModifiedFilter) as any).setSelectedItemsBasedOnQuery()
-        }
+        query['mtime'] = `"${lastModifiedParams}"`
+        updateFilter(lastModifiedFilter)
       }
 
-      return query
+      return (
+        // By definition (KQL spec) OR, AND or (GROUP) is implicit for simple cases where
+        // different or identical keys are part of the query.
+        //
+        // We list these operators for the following reasons nevertheless explicit:
+        // * request readability
+        // * code readability
+        // * complex cases readability
+        Object.keys(query)
+          .reduce((acc, prop) => {
+            const isArrayValue = Array.isArray(query[prop])
+
+            if (!isArrayValue) {
+              acc.push(`${prop}:${query[prop]}`)
+            }
+
+            if (isArrayValue) {
+              acc.push(`${prop}:(${query[prop].join(' OR ')})`)
+            }
+
+            return acc
+          }, [])
+          .join(' AND ')
+      )
     }
 
     const breadcrumbs = computed(() => {
diff --git a/packages/web-app-files/tests/unit/components/Search/List.spec.ts b/packages/web-app-files/tests/unit/components/Search/List.spec.ts
index 0ac2cff933c..18f674856a7 100644
--- a/packages/web-app-files/tests/unit/components/Search/List.spec.ts
+++ b/packages/web-app-files/tests/unit/components/Search/List.spec.ts
@@ -79,15 +79,15 @@ describe('List component', () => {
       })
       it('should set initial filter when tags are given via query param', async () => {
         const searchTerm = 'term'
-        const tagFilterQuery = 'tag1'
+        const availableTags = ['tag1', 'tag2']
         const { wrapper } = getWrapper({
-          availableTags: [tagFilterQuery],
+          availableTags,
           searchTerm,
-          tagFilterQuery
+          tagFilterQuery: availableTags.join('+')
         })
         await wrapper.vm.loadAvailableTagsTask.last
         expect(wrapper.emitted('search')[0][0]).toEqual(
-          `name:"*${searchTerm}*" tag:"${tagFilterQuery}"`
+          `name:"*${searchTerm}*" AND tag:("${availableTags[0]}" OR "${availableTags[1]}")`
         )
       })
     })
@@ -140,7 +140,7 @@ describe('List component', () => {
         })
         await wrapper.vm.loadAvailableTagsTask.last
         expect(wrapper.emitted('search')[0][0]).toEqual(
-          `name:"*${searchTerm}*" mtime:"${lastModifiedFilterQuery}"`
+          `name:"*${searchTerm}*" AND mtime:"${lastModifiedFilterQuery}"`
         )
       })
     })