|
| 1 | +# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai> |
| 2 | +# |
| 3 | +# SPDX-License-Identifier: Apache-2.0 |
| 4 | + |
| 5 | +import random |
| 6 | +import re |
| 7 | + |
| 8 | +import pytest |
| 9 | + |
| 10 | +from haystack import Document, Pipeline |
| 11 | +from haystack.components.preprocessors import DocumentSplitter |
| 12 | +from haystack.components.retrievers import InMemoryBM25Retriever |
| 13 | +from haystack.components.retrievers.sentence_window_retriever import SentenceWindowRetriever |
| 14 | +from haystack.core.pipeline.async_pipeline import AsyncPipeline |
| 15 | +from haystack.document_stores.in_memory import InMemoryDocumentStore |
| 16 | + |
| 17 | + |
| 18 | +class TestSentenceWindowRetrieverAsync: |
| 19 | + async def test_document_without_split_id(self): |
| 20 | + docs = [ |
| 21 | + Document(content="This is a text with some words. There is a ", meta={"id": "doc_0"}), |
| 22 | + Document(content="some words. There is a second sentence. And there is ", meta={"id": "doc_1"}), |
| 23 | + ] |
| 24 | + with pytest.raises(ValueError, match="The retrieved documents must have 'split_id_test' in their metadata."): |
| 25 | + retriever = SentenceWindowRetriever( |
| 26 | + document_store=InMemoryDocumentStore(), window_size=3, split_id_meta_field="split_id_test" |
| 27 | + ) |
| 28 | + await retriever.run_async(retrieved_documents=docs) |
| 29 | + |
| 30 | + @pytest.mark.asyncio |
| 31 | + async def test_document_without_source_id(self): |
| 32 | + docs = [ |
| 33 | + Document(content="This is a text with some words. There is a ", meta={"id": "doc_0", "split_id": 0}), |
| 34 | + Document( |
| 35 | + content="some words. There is a second sentence. And there is ", |
| 36 | + meta={"id": "doc_1", "split_id": 1, "source_id_test": "source1"}, |
| 37 | + ), |
| 38 | + ] |
| 39 | + with pytest.raises(ValueError, match="The retrieved documents must have 'source_id_test' in their metadata."): |
| 40 | + retriever = SentenceWindowRetriever( |
| 41 | + document_store=InMemoryDocumentStore(), window_size=3, source_id_meta_field="source_id_test" |
| 42 | + ) |
| 43 | + await retriever.run_async(retrieved_documents=docs) |
| 44 | + |
| 45 | + @pytest.mark.asyncio |
| 46 | + async def test_document_without_all_source_ids(self): |
| 47 | + docs = [ |
| 48 | + Document( |
| 49 | + content="These are words from the first section", |
| 50 | + meta={"id": "doc_1", "split_id": 0, "section_id": "section1"}, |
| 51 | + ), |
| 52 | + Document( |
| 53 | + content="These are words from the second section, but missing section_id", |
| 54 | + meta={"id": "doc_0", "split_id": 0}, |
| 55 | + ), |
| 56 | + ] |
| 57 | + with pytest.raises( |
| 58 | + ValueError, match=re.escape("The retrieved documents must have '['id', 'section_id']' in their metadata.") |
| 59 | + ): |
| 60 | + retriever = SentenceWindowRetriever( |
| 61 | + document_store=InMemoryDocumentStore(), window_size=3, source_id_meta_field=["id", "section_id"] |
| 62 | + ) |
| 63 | + await retriever.run_async(retrieved_documents=docs) |
| 64 | + |
| 65 | + @pytest.mark.asyncio |
| 66 | + async def test_run_async_invalid_window_size(self): |
| 67 | + docs = [Document(content="This is a text with some words. There is a ", meta={"id": "doc_0", "split_id": 0})] |
| 68 | + with pytest.raises(ValueError): |
| 69 | + retriever = SentenceWindowRetriever(document_store=InMemoryDocumentStore(), window_size=0) |
| 70 | + await retriever.run_async(retrieved_documents=docs) |
| 71 | + |
| 72 | + @pytest.mark.asyncio |
| 73 | + async def test_constructor_parameter_does_not_change(self): |
| 74 | + retriever = SentenceWindowRetriever(InMemoryDocumentStore(), window_size=5) |
| 75 | + assert retriever.window_size == 5 |
| 76 | + |
| 77 | + doc = { |
| 78 | + "id": "doc_0", |
| 79 | + "content": "This is a text with some words. There is a ", |
| 80 | + "source_id": "c5d7c632affc486d0cfe7b3c0f4dc1d3896ea720da2b538d6d10b104a3df5f99", |
| 81 | + "page_number": 1, |
| 82 | + "split_id": 0, |
| 83 | + "split_idx_start": 0, |
| 84 | + "_split_overlap": [{"doc_id": "doc_1", "range": (0, 23)}], |
| 85 | + } |
| 86 | + |
| 87 | + await retriever.run_async(retrieved_documents=[Document.from_dict(doc)], window_size=1) |
| 88 | + assert retriever.window_size == 5 |
| 89 | + |
| 90 | + @pytest.mark.asyncio |
| 91 | + async def test_context_documents_returned_are_ordered_by_split_idx_start(self): |
| 92 | + docs = [] |
| 93 | + accumulated_length = 0 |
| 94 | + for sent in range(10): |
| 95 | + content = f"Sentence {sent}." |
| 96 | + docs.append( |
| 97 | + Document( |
| 98 | + content=content, |
| 99 | + meta={ |
| 100 | + "id": f"doc_{sent}", |
| 101 | + "split_idx_start": accumulated_length, |
| 102 | + "source_id": "source1", |
| 103 | + "split_id": sent, |
| 104 | + }, |
| 105 | + ) |
| 106 | + ) |
| 107 | + accumulated_length += len(content) |
| 108 | + |
| 109 | + random.shuffle(docs) |
| 110 | + |
| 111 | + doc_store = InMemoryDocumentStore() |
| 112 | + doc_store.write_documents(docs) |
| 113 | + retriever = SentenceWindowRetriever(document_store=doc_store, window_size=3) |
| 114 | + |
| 115 | + # run the retriever with a document whose content = "Sentence 4." |
| 116 | + result = await retriever.run_async(retrieved_documents=[doc for doc in docs if doc.content == "Sentence 4."]) |
| 117 | + |
| 118 | + # assert that the context documents are in the correct order |
| 119 | + assert len(result["context_documents"]) == 7 |
| 120 | + assert [doc.meta["split_idx_start"] for doc in result["context_documents"]] == [11, 22, 33, 44, 55, 66, 77] |
| 121 | + |
| 122 | + @pytest.mark.asyncio |
| 123 | + async def test_run_async_custom_fields(self): |
| 124 | + docs = [] |
| 125 | + accumulated_length = 0 |
| 126 | + for sent in range(10): |
| 127 | + content = f"Sentence {sent}." |
| 128 | + docs.append( |
| 129 | + Document( |
| 130 | + content=content, |
| 131 | + meta={ |
| 132 | + "id": f"doc_{sent}", |
| 133 | + # Missing split_idx_start |
| 134 | + "source_id_test": "source1", |
| 135 | + "split_id_test": sent, |
| 136 | + }, |
| 137 | + ) |
| 138 | + ) |
| 139 | + accumulated_length += len(content) |
| 140 | + |
| 141 | + random.shuffle(docs) |
| 142 | + |
| 143 | + doc_store = InMemoryDocumentStore() |
| 144 | + doc_store.write_documents(docs) |
| 145 | + retriever = SentenceWindowRetriever( |
| 146 | + document_store=doc_store, |
| 147 | + window_size=3, |
| 148 | + source_id_meta_field="source_id_test", |
| 149 | + split_id_meta_field="split_id_test", |
| 150 | + ) |
| 151 | + |
| 152 | + # run the retriever with a document whose content = "Sentence 4." |
| 153 | + result = await retriever.run_async(retrieved_documents=[doc for doc in docs if doc.content == "Sentence 4."]) |
| 154 | + assert len(result["context_documents"]) == 7 |
| 155 | + |
| 156 | + @pytest.mark.asyncio |
| 157 | + async def test_run_async_with_multiple_source_ids(self): |
| 158 | + docs = [ |
| 159 | + Document(content="This is the first chunk.", meta={"section": "1", "split_id": 0, "source_id": "source1"}), |
| 160 | + Document(content="This is the second chunk.", meta={"section": "1", "split_id": 1, "source_id": "source1"}), |
| 161 | + Document(content="This is the third chunk.", meta={"section": "1", "split_id": 2, "source_id": "source1"}), |
| 162 | + Document( |
| 163 | + content="This is a chunk from section 2.", meta={"section": "2", "split_id": 3, "source_id": "source1"} |
| 164 | + ), |
| 165 | + ] |
| 166 | + doc_store = InMemoryDocumentStore() |
| 167 | + doc_store.write_documents(docs) |
| 168 | + |
| 169 | + retriever = SentenceWindowRetriever( |
| 170 | + document_store=doc_store, window_size=5, source_id_meta_field=["section", "source_id"] |
| 171 | + ) |
| 172 | + result = await retriever.run_async( |
| 173 | + retrieved_documents=[ |
| 174 | + Document( |
| 175 | + content="This is the second chunk.", meta={"section": "1", "split_id": 1, "source_id": "source1"} |
| 176 | + ) |
| 177 | + ] |
| 178 | + ) |
| 179 | + |
| 180 | + assert len(result["context_windows"]) == 1 |
| 181 | + assert len(result["context_documents"]) == 3 |
| 182 | + assert all(doc.meta["section"] == "1" for doc in result["context_documents"]) |
| 183 | + |
| 184 | + @pytest.mark.asyncio |
| 185 | + @pytest.mark.integration |
| 186 | + async def test_run_async_with_pipeline(self): |
| 187 | + splitter = DocumentSplitter(split_length=1, split_overlap=0, split_by="period") |
| 188 | + text = ( |
| 189 | + "This is a text with some words. There is a second sentence. And there is also a third sentence. " |
| 190 | + "It also contains a fourth sentence. And a fifth sentence. And a sixth sentence. And a seventh sentence" |
| 191 | + ) |
| 192 | + doc = Document(content=text) |
| 193 | + docs = splitter.run([doc]) |
| 194 | + doc_store = InMemoryDocumentStore() |
| 195 | + doc_store.write_documents(docs["documents"]) |
| 196 | + |
| 197 | + pipe = AsyncPipeline() |
| 198 | + pipe.add_component("bm25_retriever", InMemoryBM25Retriever(doc_store, top_k=1)) |
| 199 | + pipe.add_component( |
| 200 | + "sentence_window_retriever", SentenceWindowRetriever(document_store=doc_store, window_size=2) |
| 201 | + ) |
| 202 | + pipe.connect("bm25_retriever", "sentence_window_retriever") |
| 203 | + result = await pipe.run_async({"bm25_retriever": {"query": "third"}}) |
| 204 | + |
| 205 | + assert result["sentence_window_retriever"]["context_windows"] == [ |
| 206 | + "This is a text with some words. There is a second sentence. And there is also a third sentence. " |
| 207 | + "It also contains a fourth sentence. And a fifth sentence." |
| 208 | + ] |
| 209 | + assert len(result["sentence_window_retriever"]["context_documents"]) == 5 |
| 210 | + |
| 211 | + result = await pipe.run_async( |
| 212 | + {"bm25_retriever": {"query": "third"}, "sentence_window_retriever": {"window_size": 1}} |
| 213 | + ) |
| 214 | + assert result["sentence_window_retriever"]["context_windows"] == [ |
| 215 | + " There is a second sentence. And there is also a third sentence. It also contains a fourth sentence." |
| 216 | + ] |
| 217 | + assert len(result["sentence_window_retriever"]["context_documents"]) == 3 |
| 218 | + |
| 219 | + @pytest.mark.asyncio |
| 220 | + @pytest.mark.integration |
| 221 | + async def test_serialization_deserialization_in_pipeline(self): |
| 222 | + doc_store = InMemoryDocumentStore() |
| 223 | + pipe = AsyncPipeline() |
| 224 | + pipe.add_component("bm25_retriever", InMemoryBM25Retriever(doc_store, top_k=1)) |
| 225 | + pipe.add_component( |
| 226 | + "sentence_window_retriever", SentenceWindowRetriever(document_store=doc_store, window_size=2) |
| 227 | + ) |
| 228 | + pipe.connect("bm25_retriever", "sentence_window_retriever") |
| 229 | + |
| 230 | + serialized = pipe.to_dict() |
| 231 | + deserialized = AsyncPipeline.from_dict(serialized) |
| 232 | + |
| 233 | + assert deserialized == pipe |
0 commit comments