package search import ( "context" "path/filepath" "testing" "github.com/blevesearch/bleve/v2" "github.com/blevesearch/bleve/v2/analysis/lang/en" "github.com/blevesearch/bleve/v2/analysis/lang/ru" "github.com/blevesearch/bleve/v2/registry" "github.com/stretchr/testify/require" ) // The design leaves "mixed Russian/English search" open, with warren's // per-index analyzer choice as the starting point. These tests are the evidence // the decision was made on. They run in three steps: // // 1. what one analyzer actually does to the other language's words, // 2. what that costs a genuinely mixed document under per-document routing, // 3. that per-line routing removes the cost. const ( enSpecBody = "The approved revision is the one a bot reads. Every read resolves a git tree\n" + "and reads blobs from it. Proposals live on their own branch and are merged\n" + "only after a human approves them.\n" ruSpecBody = "Одобренная ревизия — это та, которую читают агенты. Каждое чтение разрешает\n" + "дерево гита и читает из него блобы. Предложения живут в отдельной ветке и\n" + "объединяются только после проверки человеком.\n" // A real shape for this corpus: Russian prose around English requirements // quoted verbatim from the upstream document they came from. mixedSpecBody = "Этот документ описывает требования к вложениям и двоичным файлам в спецификациях.\n" + "The requirement is quoted verbatim from upstream: attachments larger than the\n" + "configured cap are rejected by the daemon before the push is accepted.\n" + "Ограничение размера вложений обсуждается отдельно и пока не выбрано.\n" ) func analyze(t *testing.T, analyzer, text string) []string { t.Helper() a, err := registry.NewCache().AnalyzerNamed(analyzer) require.NoError(t, err) var out []string for _, tok := range a.Analyze([]byte(text)) { out = append(out, string(tok.Term)) } return out } // TestOneAnalyzerCannotStemBothLanguages records the premise: bleve's per-index // analyzer choice does not break the other language, it merely stops stemming // it. Foreign-script terms survive as literals — which is why warren's // single-analyzer index was usable — but they survive unstemmed, so singular // and plural stop being the same word. func TestOneAnalyzerCannotStemBothLanguages(t *testing.T) { // Its own language: stemmed, stop words dropped. require.Equal(t, []string{"index", "rebuild", "document"}, analyze(t, en.AnalyzerName, "indexes rebuild all the documents")) require.Equal(t, []string{"индекс", "перестраива", "документ"}, analyze(t, ru.AnalyzerName, "индексы перестраивает все документы")) // The other language: passed through whole, stop words and all. require.Equal(t, []string{"indexes", "rebuild", "all", "the", "documents"}, analyze(t, ru.AnalyzerName, "indexes rebuild all the documents")) require.Equal(t, []string{"индексы", "перестраивает", "все", "документы"}, analyze(t, en.AnalyzerName, "индексы перестраивает все документы")) } // TestPerDocumentRoutingLosesTheMinorityLanguage is the negative result that // rules out the obvious reading of the design's open item — "detect the // dominant language of the document and write the matching field". // // The document below is dominantly Russian, so per-document routing files all // of it, English requirements included, under the Russian analyzer. The Russian // half then searches correctly and the English half is reachable only by exact // word form: "attachments" is found, "attachment" is not. func TestPerDocumentRoutingLosesTheMinorityLanguage(t *testing.T) { idx, err := bleve.New(filepath.Join(t.TempDir(), "per-document.bleve"), buildMapping()) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, idx.Close()) }) // Exactly what per-document routing produces: one field, chosen by the // document's dominant language. lang := DetectIn(mixedSpecBody, DefaultLang) require.Equal(t, LangRU, lang, "the fixture is dominantly Russian") require.NoError(t, idx.Index("mixed", map[string]any{ fieldSpace: "~bigbes/specs", fieldTitle: "Вложения", fieldBodyRU: mixedSpecBody, })) found := func(term, field string) bool { q := bleve.NewMatchQuery(term) q.SetField(field) res, err := idx.Search(bleve.NewSearchRequest(q)) require.NoError(t, err) return res.Total > 0 } // The dominant half is fine. require.True(t, found("вложение", fieldBodyRU), "singular Russian finds the plural in the text") // The minority half is not: it is in the index, but only as a literal. require.True(t, found("attachments", fieldBodyRU), "the exact English word form is still there") require.False(t, found("attachment", fieldBodyRU), "per-document routing leaves the English half unstemmed: singular misses the plural") } // TestPerLineRoutingFindsBothHalvesOfAMixedDocument is the same document // through the real path. Each line lands in the field whose analyzer stems it, // and both halves answer stemmed queries. func TestPerLineRoutingFindsBothHalvesOfAMixedDocument(t *testing.T) { c := newCorpus(t, "~bigbes/specs", "rev1"). add("specs/storage.md", "---\nid: SPEC-0001\ntitle: Storage model\nstatus: draft\n---\n\n"+enSpecBody). add("specs/hranenie.md", "---\nid: SPEC-0002\ntitle: Модель хранения\nstatus: draft\n---\n\n"+ruSpecBody). add("specs/attachments.md", "---\nid: SPEC-0003\ntitle: Вложения и двоичные файлы\nstatus: draft\n---\n\n"+mixedSpecBody) idx := indexCorpus(t, c) // Singular English query, plural in the text: only the stemmer bridges it. require.Equal(t, []string{"SPEC-0003"}, hitIDs(t, idx, Query{Text: "attachment"}), "the English half of the mixed document is stemmed") // Singular Russian query, plural in the text. require.Equal(t, []string{"SPEC-0003"}, hitIDs(t, idx, Query{Text: "вложение"}), "the Russian half of the mixed document is stemmed") // The single-language documents are unaffected. require.Equal(t, []string{"SPEC-0001"}, hitIDs(t, idx, Query{Text: "proposal"})) require.Equal(t, []string{"SPEC-0002"}, hitIDs(t, idx, Query{Text: "предложение"})) } // TestMixedDocumentIsLabelledByItsDominantLanguage checks the label a hit // carries. The document is routed per line, but it is still one document and // reports one language — which is what picks the snippet's half. func TestMixedDocumentIsLabelledByItsDominantLanguage(t *testing.T) { c := newCorpus(t, "~bigbes/specs", "rev1"). add("specs/storage.md", "---\nid: SPEC-0001\ntitle: Storage model\n---\n\n"+enSpecBody). add("specs/attachments.md", "---\nid: SPEC-0003\ntitle: Вложения и двоичные файлы\n---\n\n"+mixedSpecBody) idx := indexCorpus(t, c) res, err := idx.Search(context.Background(), Query{Text: "attachment"}) require.NoError(t, err) require.Len(t, res.Hits, 1) require.Equal(t, LangRU, res.Hits[0].Lang) require.Contains(t, res.Hits[0].Snippet, "attachments", "the snippet comes from the half that matched, even though it is not the document's language") res, err = idx.Search(context.Background(), Query{Text: "proposal"}) require.NoError(t, err) require.Len(t, res.Hits, 1) require.Equal(t, LangEN, res.Hits[0].Lang) } // TestCyrillicTitleIsSearchableAndBoosted checks that titles are routed too: a // Russian title lands in the Russian title field, so a query naming a document // returns it above documents that merely mention it. func TestCyrillicTitleIsSearchableAndBoosted(t *testing.T) { c := newCorpus(t, "~bigbes/specs", "rev1"). add("specs/hranenie.md", "---\nid: SPEC-0002\ntitle: Модель хранения данных\n---\n\n"+ruSpecBody). add("specs/mention.md", "---\nid: SPEC-0004\ntitle: Прочее\n---\n\n"+ "Здесь упоминается модель хранения данных, но документ совсем о другом предмете.\n") idx := indexCorpus(t, c) require.Equal(t, []string{"SPEC-0002", "SPEC-0004"}, hitIDs(t, idx, Query{Text: "модель хранения"}), "the document named by the query outranks the one that mentions it") }