feat(collections): show how a search finds passages

The collections list now reports search_mode: semantic for the chromem
engine and hybrid for the postgres engine, which also matches words.
The Knowledge page shows it in plain words, so a person knows whether
an exact code or name can be found.

Assisted-by: Claude:claude-sonnet-5-5
This commit is contained in:
Ettore Di Giacinto committed 2026-11-05 09:03:52 +00:00
1 parent eceb57e1a2
commit ace0dfcae8
19 files changed
+149 -5

No files matched your search

@@ -11,7 +11,7 @@ import (
// ListCollectionsEndpoint handles /api/collections (GET).
// @Summary List your collections.
// @Description The knowledge-base collections of the caller. An administrator can add all_users=true to also get the other users' collections in user_groups. embedding_model names the model that indexes and searches the collections (collections_embedding_model), and embedding_model_installed says whether this server has it: without it, adding files and searching fail. Needs the collections feature.
// @Description The knowledge-base collections of the caller. An administrator can add all_users=true to also get the other users' collections in user_groups. embedding_model names the model that indexes and searches the collections (collections_embedding_model), and embedding_model_installed says whether this server has it: without it, adding files and searching fail. search_mode says how a search finds passages: semantic (by meaning only) or hybrid (by meaning and by keyword). Needs the collections feature.
// @Tags collections
// @Success 200 {object} schema.CollectionList "The collections"
// @Router /api/collections [get]
@@ -30,6 +30,7 @@ func ListCollectionsEndpoint(app *application.Application) echo.HandlerFunc {
"count": len(cols),
"embedding_model": model,
"embedding_model_installed": embeddingModelInstalled(app, model),
"search_mode": collectionSearchMode(app.ApplicationConfig().Collections.VectorEngine),
}
// Admin cross-user aggregation
@@ -57,6 +58,16 @@ func ListCollectionsEndpoint(app *application.Application) echo.HandlerFunc {
}
}
// collectionSearchMode says how a search of the vector engine finds passages.
// The chromem and localai engines compare embeddings only. The postgres engine
// also matches words (BM25) and mixes the two lists.
func collectionSearchMode(engine string) string {
if engine == "postgres" {
return "hybrid"
}
return "semantic"
}
// CreateCollectionEndpoint handles /api/collections (POST).
// @Summary Create a collection.
// @Description Makes an empty collection under the name in the body.
@@ -48,3 +48,15 @@ var _ = Describe("the embedding model of the collections", func() {
Expect(embeddingModelInstalled(app, "plain.gguf")).To(BeTrue())
})
})
var _ = Describe("the search mode of the collections", func() {
It("is semantic for the engines that compare embeddings only", func() {
Expect(collectionSearchMode("chromem")).To(Equal("semantic"))
Expect(collectionSearchMode("localai")).To(Equal("semantic"))
Expect(collectionSearchMode("")).To(Equal("semantic"))
})
It("is hybrid for the postgres engine, which also matches words", func() {
Expect(collectionSearchMode("postgres")).To(Equal("hybrid"))
})
})
@@ -27,6 +27,30 @@ test.describe('Collections page', () => {
await expect(page.getByRole('button', { name: 'New collection' }).first()).toBeVisible()
})
test('says how a search finds passages, in plain words', async ({ page }) => {
const list = mode => route => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ collections: ['docs'], search_mode: mode, embedding_model_installed: true }) })
await page.route(/\/api\/collections(\?.*)?$/, list('semantic'))
await page.goto('/app/collections')
await expect(page.getByTestId('search-mode')).toContainText('Search: semantic.')
await expect(page.getByTestId('search-mode')).toContainText('by meaning, not by exact words or codes')
await page.unroute(/\/api\/collections(\?.*)?$/)
await page.route(/\/api\/collections(\?.*)?$/, list('hybrid'))
await page.goto('/app/collections')
await expect(page.getByTestId('search-mode')).toContainText('Search: hybrid.')
await expect(page.getByTestId('search-mode')).toContainText('by meaning and by exact words')
await page.setViewportSize({ width: 360, height: 740 })
await expect(page.getByTestId('search-mode')).toBeVisible()
// The page animates in; wait for the layout to settle before measuring.
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(0)
})
test('says nothing about the search mode when the server does not report it', async ({ page }) => {
await expect(page.getByTestId('memory-empty')).toBeVisible()
await expect(page.getByTestId('search-mode')).toHaveCount(0)
})
test('new-collection name field accepts input', async ({ page }) => {
await page.getByRole('button', { name: 'New collection' }).first().click()
const input = page.getByLabel('New collection name')
@@ -57,5 +57,12 @@
"noneSet": "Für Sammlungen ist kein Embedding-Modell festgelegt (collections_embedding_model), daher schlagen das Hinzufügen von Dateien und die Suche fehl.",
"install": "In Modelle suchen",
"askAdmin": "Bitte eine Administratorin oder einen Administrator, es zu installieren."
},
"searchMode": {
"label": "Suche: {{mode}}",
"semantic": "semantisch",
"hybrid": "hybrid",
"semanticHint": "Sie findet Textstellen nach Bedeutung, nicht nach genauen Wörtern oder Codes.",
"hybridHint": "Sie findet Textstellen nach Bedeutung und nach genauen Wörtern."
}
}
@@ -146,5 +146,12 @@
"noneSet": "No embedding model is set for collections (collections_embedding_model), so adding files and searching fail.",
"install": "Find it in Models",
"askAdmin": "Ask an administrator to install it."
},
"searchMode": {
"label": "Search: {{mode}}",
"semantic": "semantic",
"hybrid": "hybrid",
"semanticHint": "It finds passages by meaning, not by exact words or codes.",
"hybridHint": "It finds passages by meaning and by exact words."
}
}
@@ -57,5 +57,12 @@
"noneSet": "No hay ningún modelo de embeddings configurado para las colecciones (collections_embedding_model), así que añadir archivos y buscar fallan.",
"install": "Buscarlo en Modelos",
"askAdmin": "Pide a un administrador que lo instale."
},
"searchMode": {
"label": "Búsqueda: {{mode}}",
"semantic": "semántica",
"hybrid": "híbrida",
"semanticHint": "Encuentra pasajes por su significado, no por palabras o códigos exactos.",
"hybridHint": "Encuentra pasajes por su significado y por palabras exactas."
}
}
@@ -57,5 +57,12 @@
"noneSet": "Aucun modèle d'embedding n'est défini pour les collections (collections_embedding_model) : l'ajout de fichiers et la recherche échouent.",
"install": "Le trouver dans Modèles",
"askAdmin": "Demandez à un administrateur de l'installer."
},
"searchMode": {
"label": "Recherche : {{mode}}",
"semantic": "sémantique",
"hybrid": "hybride",
"semanticHint": "Elle trouve des passages par le sens, pas par des mots ou des codes exacts.",
"hybridHint": "Elle trouve des passages par le sens et par des mots exacts."
}
}
@@ -57,5 +57,12 @@
"noneSet": "Tidak ada model embedding yang diatur untuk koleksi (collections_embedding_model), jadi menambah file dan pencarian gagal.",
"install": "Cari di Model",
"askAdmin": "Minta administrator untuk memasangnya."
},
"searchMode": {
"label": "Pencarian: {{mode}}",
"semantic": "semantik",
"hybrid": "hibrida",
"semanticHint": "Pencarian menemukan bagian teks berdasarkan makna, bukan kata atau kode yang persis.",
"hybridHint": "Pencarian menemukan bagian teks berdasarkan makna dan kata yang persis."
}
}
@@ -57,5 +57,12 @@
"noneSet": "Nessun modello di embedding è impostato per le raccolte (collections_embedding_model), quindi l'aggiunta di file e la ricerca non funzionano.",
"install": "Trovalo in Modelli",
"askAdmin": "Chiedi a un amministratore di installarlo."
},
"searchMode": {
"label": "Ricerca: {{mode}}",
"semantic": "semantica",
"hybrid": "ibrida",
"semanticHint": "Trova i passaggi per significato, non per parole o codici esatti.",
"hybridHint": "Trova i passaggi per significato e per parole esatte."
}
}
@@ -57,5 +57,12 @@
"noneSet": "컬렉션에 임베딩 모델이 설정되어 있지 않아(collections_embedding_model) 파일 추가와 검색이 실패합니다.",
"install": "모델에서 찾기",
"askAdmin": "관리자에게 설치를 요청하세요."
},
"searchMode": {
"label": "검색: {{mode}}",
"semantic": "의미 기반",
"hybrid": "하이브리드",
"semanticHint": "정확한 단어나 코드가 아니라 의미로 구절을 찾습니다.",
"hybridHint": "의미와 정확한 단어로 구절을 찾습니다."
}
}
@@ -57,5 +57,12 @@
"noneSet": "Nenhum modelo de embeddings está definido para as coleções (collections_embedding_model), então adicionar arquivos e pesquisar falham.",
"install": "Encontrar em Modelos",
"askAdmin": "Peça a um administrador para instalá-lo."
},
"searchMode": {
"label": "Busca: {{mode}}",
"semantic": "semântica",
"hybrid": "híbrida",
"semanticHint": "Encontra trechos pelo significado, não por palavras ou códigos exatos.",
"hybridHint": "Encontra trechos pelo significado e por palavras exatas."
}
}
@@ -57,5 +57,12 @@
"noneSet": "未为集合设置嵌入模型(collections_embedding_model),因此添加文件和搜索都会失败。",
"install": "在模型中查找",
"askAdmin": "请让管理员安装它。"
},
"searchMode": {
"label": "搜索:{{mode}}",
"semantic": "语义",
"hybrid": "混合",
"semanticHint": "按含义查找段落,不按精确的词或代码匹配。",
"hybridHint": "按含义和精确的词查找段落。"
}
}
@@ -60,6 +60,9 @@ export default function Collections() {
// The model that indexes and searches the collections, and whether this
// server has it. Without it every upload and search fails.
const [embedding, setEmbedding] = useState({ model: '', installed: true })
// How a search finds passages: 'semantic' or 'hybrid'. '' while unknown
// (an older server does not say).
const [searchMode, setSearchMode] = useState('')
const selectedUser = params.get('user_id') || undefined
@@ -74,6 +77,7 @@ export default function Collections() {
setCollections((Array.isArray(data.collections) ? data.collections : []).map(nameOf).filter(Boolean))
setUserGroups(data.user_groups || null)
setEmbedding({ model: data.embedding_model || '', installed: data.embedding_model_installed !== false })
setSearchMode(data.search_mode === 'hybrid' || data.search_mode === 'semantic' ? data.search_mode : '')
setLoadError(null)
} catch (err) {
// A failed read shows an error in place of the list, never the empty
@@ -257,6 +261,13 @@ export default function Collections() {
</form>
)}
{!loading && !loadError && searchMode && (
<p className="lib-mode" data-testid="search-mode">
<strong>{t('collections:searchMode.label', { mode: t(`collections:searchMode.${searchMode}`) })}.</strong>{' '}
{t(`collections:searchMode.${searchMode}Hint`)}
</p>
)}
{!loading && !loadError && !embedding.installed && (
<Callout
tone="warn"
+2
View File
@@ -197,6 +197,8 @@ a.lib-chip__link:hover { text-decoration: underline; text-underline-offset: 3px;
.lib-kv__pair { display: contents; }
.lib-gap { margin-left: var(--dk-space-2); }
.lib-fine { margin: 0; font-size: var(--fs-meta); }
.lib-mode { margin: 0 0 var(--dk-space-3); font-size: var(--fs-small); color: var(--dk-muted); overflow-wrap: anywhere; }
.lib-mode strong { color: var(--dk-text); font-weight: 600; }
.lib-create--page { margin-bottom: var(--dk-space-4); max-width: 520px; }
.lib-more { align-self: flex-start; background: none; border: 0; padding: 0; font: inherit; cursor: pointer; color: var(--dk-accent-text); text-decoration: underline; text-underline-offset: 3px; }
+3
View File
@@ -9,6 +9,9 @@ type CollectionList struct {
// adding files and searching then fail until it is installed.
EmbeddingModel string `json:"embedding_model"`
EmbeddingModelInstalled bool `json:"embedding_model_installed"`
// SearchMode is how a search finds passages: "semantic" (by meaning only)
// or "hybrid" (by meaning and by keyword).
SearchMode string `json:"search_mode"`
// UserGroups holds the collections of the other users. Only an administrator
// who asks for all users gets it.
UserGroups map[string]CollectionGroup `json:"user_groups,omitempty"`
+4
View File
@@ -36,6 +36,10 @@ and names the model; an administrator gets a button that opens the Models
page with a search for it. `GET /api/collections` reports the same in
`embedding_model` and `embedding_model_installed`.
The Knowledge page also shows how a search finds passages, for example
*Search: semantic*. `GET /api/collections` reports it in `search_mode`: `semantic`
for the `chromem` engine, and `hybrid` for the `postgres` engine.
## Who sees a collection
- With authentication off, there is one set of collections.
+5 -1
View File
@@ -11214,7 +11214,7 @@ const docTemplate = `{
},
"/api/collections": {
"get": {
"description": "The knowledge-base collections of the caller. An administrator can add all_users=true to also get the other users' collections in user_groups. embedding_model names the model that indexes and searches the collections (collections_embedding_model), and embedding_model_installed says whether this server has it: without it, adding files and searching fail. Needs the collections feature.",
"description": "The knowledge-base collections of the caller. An administrator can add all_users=true to also get the other users' collections in user_groups. embedding_model names the model that indexes and searches the collections (collections_embedding_model), and embedding_model_installed says whether this server has it: without it, adding files and searching fail. search_mode says how a search finds passages: semantic (by meaning only) or hybrid (by meaning and by keyword). Needs the collections feature.",
"tags": [
"collections"
],
@@ -26173,6 +26173,10 @@ const docTemplate = `{
"embedding_model_installed": {
"type": "boolean"
},
"search_mode": {
"description": "SearchMode is how a search finds passages: \"semantic\" (by meaning only)\nor \"hybrid\" (by meaning and by keyword).",
"type": "string"
},
"user_groups": {
"description": "UserGroups holds the collections of the other users. Only an administrator\nwho asks for all users gets it.",
"type": "object",
+5 -1
View File
@@ -11211,7 +11211,7 @@
},
"/api/collections": {
"get": {
"description": "The knowledge-base collections of the caller. An administrator can add all_users=true to also get the other users' collections in user_groups. embedding_model names the model that indexes and searches the collections (collections_embedding_model), and embedding_model_installed says whether this server has it: without it, adding files and searching fail. Needs the collections feature.",
"description": "The knowledge-base collections of the caller. An administrator can add all_users=true to also get the other users' collections in user_groups. embedding_model names the model that indexes and searches the collections (collections_embedding_model), and embedding_model_installed says whether this server has it: without it, adding files and searching fail. search_mode says how a search finds passages: semantic (by meaning only) or hybrid (by meaning and by keyword). Needs the collections feature.",
"tags": [
"collections"
],
@@ -26170,6 +26170,10 @@
"embedding_model_installed": {
"type": "boolean"
},
"search_mode": {
"description": "SearchMode is how a search finds passages: \"semantic\" (by meaning only)\nor \"hybrid\" (by meaning and by keyword).",
"type": "string"
},
"user_groups": {
"description": "UserGroups holds the collections of the other users. Only an administrator\nwho asks for all users gets it.",
"type": "object",
+8 -2
View File
@@ -7941,6 +7941,11 @@ definitions:
type: string
embedding_model_installed:
type: boolean
search_mode:
description: |-
SearchMode is how a search finds passages: "semantic" (by meaning only)
or "hybrid" (by meaning and by keyword).
type: string
user_groups:
additionalProperties:
$ref: '#/definitions/schema.CollectionGroup'
@@ -19948,8 +19953,9 @@ paths:
can add all_users=true to also get the other users'' collections in user_groups.
embedding_model names the model that indexes and searches the collections
(collections_embedding_model), and embedding_model_installed says whether
this server has it: without it, adding files and searching fail. Needs the
collections feature.'
this server has it: without it, adding files and searching fail. search_mode
says how a search finds passages: semantic (by meaning only) or hybrid (by
meaning and by keyword). Needs the collections feature.'
responses:
"200":
description: The collections