raglite-toolkit 1.2.0__tar.gz → 1.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/CHANGELOG.md +18 -0
  2. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/PKG-INFO +3 -2
  3. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/README.md +1 -0
  4. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/pyproject.toml +1 -1
  5. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/config.py +5 -0
  6. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/constants.py +1 -1
  7. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/core/collection.py +21 -3
  8. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/core/document.py +35 -21
  9. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/loaders/web.py +2 -1
  10. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/vectordb/qdrant.py +31 -9
  11. raglite_toolkit-1.2.1/tests/unit/test_regressions.py +210 -0
  12. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/.github/workflows/pr-verify.yml +0 -0
  13. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/.github/workflows/publish.yml +0 -0
  14. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/.gitignore +0 -0
  15. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/ARCHITECTURE.md +0 -0
  16. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/LICENSE +0 -0
  17. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/examples/basic.py +0 -0
  18. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/examples/custom_store_example.py +0 -0
  19. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/examples/multi_provider.py +0 -0
  20. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/examples/ollama_test.py +0 -0
  21. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/examples/qdrant_example.py +0 -0
  22. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/examples/sample.txt +0 -0
  23. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/examples/serve.py +0 -0
  24. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/scripts/pre-commit.sh +0 -0
  25. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/scripts/pre-release.sh +0 -0
  26. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/__init__.py +0 -0
  27. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/api/__init__.py +0 -0
  28. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/api/schemas.py +0 -0
  29. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/api/server.py +0 -0
  30. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/chunking/__init__.py +0 -0
  31. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/chunking/base.py +0 -0
  32. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/chunking/recursive.py +0 -0
  33. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/cli.py +0 -0
  34. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/core/__init__.py +0 -0
  35. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/embeddings/__init__.py +0 -0
  36. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/embeddings/base.py +0 -0
  37. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/embeddings/factory.py +0 -0
  38. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/embeddings/local.py +0 -0
  39. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/embeddings/models.py +0 -0
  40. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/embeddings/remote.py +0 -0
  41. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/errors.py +0 -0
  42. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/llm/__init__.py +0 -0
  43. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/llm/answer.py +0 -0
  44. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/llm/factory.py +0 -0
  45. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/llm/models.py +0 -0
  46. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/llm/prompt.py +0 -0
  47. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/loaders/__init__.py +0 -0
  48. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/loaders/base.py +0 -0
  49. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/loaders/directory.py +0 -0
  50. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/loaders/docx.py +0 -0
  51. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/loaders/json.py +0 -0
  52. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/loaders/markdown.py +0 -0
  53. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/loaders/pdf.py +0 -0
  54. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/loaders/txt.py +0 -0
  55. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/retrieval/__init__.py +0 -0
  56. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/retrieval/retriever.py +0 -0
  57. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/types.py +0 -0
  58. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/utils/__init__.py +0 -0
  59. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/utils/hash.py +0 -0
  60. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/utils/logger.py +0 -0
  61. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/vectordb/__init__.py +0 -0
  62. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/vectordb/base.py +0 -0
  63. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/vectordb/factory.py +0 -0
  64. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/vectordb/memory.py +0 -0
  65. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/src/raglite/vectordb/pinecone.py +0 -0
  66. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/__init__.py +0 -0
  67. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/conftest.py +0 -0
  68. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/integration/__init__.py +0 -0
  69. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/integration/test_api.py +0 -0
  70. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/integration/test_ask.py +0 -0
  71. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/integration/test_collection.py +0 -0
  72. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/integration/test_document.py +0 -0
  73. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/integration/test_ollama.py +0 -0
  74. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/unit/__init__.py +0 -0
  75. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/unit/test_chunking.py +0 -0
  76. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/unit/test_cli.py +0 -0
  77. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/unit/test_config.py +0 -0
  78. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/unit/test_directory_loader.py +0 -0
  79. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/unit/test_errors.py +0 -0
  80. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/unit/test_hash.py +0 -0
  81. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/unit/test_loaders.py +0 -0
  82. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/unit/test_prompt.py +0 -0
  83. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/unit/test_retriever.py +0 -0
  84. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/unit/test_vectordb.py +0 -0
  85. {raglite_toolkit-1.2.0 → raglite_toolkit-1.2.1}/tests/unit/test_web_loader.py +0 -0
@@ -2,6 +2,24 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.2.1] - 2026-10-02
6
+
7
+ ### Fixed
8
+ - **URL sources:** `Document.build()` no longer fails with `LoaderError: File does not exist` for web URLs, so URLs work on their own and inside a `DocumentCollection`.
9
+ - **Custom `VectorStore` instances:** Passing a `VectorStore` subclass instance as `{"vectorStore": store}` no longer fails config validation, so the documented custom store usage works.
10
+ - **Qdrant shared collections:** When `indexName` is set, documents sharing one Qdrant collection no longer wipe each other. `reset()`, search and index metadata are now scoped to each document's namespace instead of the whole collection. Without `indexName`, behaviour is unchanged.
11
+ - **Shared `VectorStore` instances in collections:** `DocumentCollection.build()` now raises `ConfigError` when a single `VectorStore` instance would be shared by more than one document, instead of each document silently resetting the previous one's index. Pass a vector store provider config instead.
12
+ - **Web URL re-indexing:** URL sources are now fingerprinted by their fetched content rather than the URL string, so a changed page is re-indexed on the next `build()`.
13
+ - **Query embedder after reload:** Searching an existing index in a new process now embeds queries with the provider and model the index was built with, rather than the constructor default. Configured credentials are reused when the provider matches.
14
+ - **Collection search errors:** `DocumentCollection.search()` (and `ask`/`ask_stream`) now logs per-document search failures instead of silently dropping them, and raises if every document fails.
15
+ - **Web loader User-Agent:** Now reports the actual package version.
16
+
17
+ ### Upgrade notes
18
+ - As with every release, cached indexes are rebuilt once on first `build()` because the package version is part of the cache key.
19
+ - Existing Qdrant indexes created with `indexName` are rebuilt once. Their old untagged points stay in the collection but are no longer returned by search; drop and recreate the collection to remove them.
20
+ - Each `build()` on a URL source now fetches the page to check for changes, even when the cached index is reused.
21
+ - When every document in a collection fails to search, the REST server now returns an error response (400 for RAGLite errors, 500 otherwise) instead of empty results.
22
+
5
23
  ## [1.2.0] - 2026-08-02
6
24
 
7
25
  ### Added
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: raglite-toolkit
3
- Version: 1.2.0
3
+ Version: 1.2.1
4
4
  Summary: Build semantic search, multi-provider question answering, and REST APIs over your documents in a few lines of Python.
5
5
  Project-URL: Homepage, https://github.com/creatorpiyush/raglite-py
6
6
  Project-URL: Repository, https://github.com/creatorpiyush/raglite-py
@@ -182,6 +182,7 @@ doc = Document("./policy.pdf", {
182
182
  ```
183
183
 
184
184
  ### Qdrant Store
185
+ By default each document gets its own collection (`raglite_<namespace>`). Set `indexName` to keep several documents in one shared collection; each document's points are tagged with its namespace, so rebuilding one document never affects the others.
185
186
  ```python
186
187
  doc = Document("./policy.pdf", {
187
188
  "vectorStore": {
@@ -136,6 +136,7 @@ doc = Document("./policy.pdf", {
136
136
  ```
137
137
 
138
138
  ### Qdrant Store
139
+ By default each document gets its own collection (`raglite_<namespace>`). Set `indexName` to keep several documents in one shared collection; each document's points are tagged with its namespace, so rebuilding one document never affects the others.
139
140
  ```python
140
141
  doc = Document("./policy.pdf", {
141
142
  "vectorStore": {
@@ -8,7 +8,7 @@ packages = ["src/raglite"]
8
8
 
9
9
  [project]
10
10
  name = "raglite-toolkit"
11
- version = "1.2.0"
11
+ version = "1.2.1"
12
12
  description = "Build semantic search, multi-provider question answering, and REST APIs over your documents in a few lines of Python."
13
13
  readme = "README.md"
14
14
  requires-python = ">=3.10"
@@ -10,6 +10,7 @@ from .constants import (
10
10
  DEFAULT_TOP_K,
11
11
  )
12
12
  from .types import EmbeddingProviderConfig, LLMProviderConfig, VectorStoreProviderConfig
13
+ from .vectordb.base import VectorStore
13
14
 
14
15
 
15
16
  class DocumentOptions(BaseModel):
@@ -44,6 +45,10 @@ def resolve_config(options: Optional[Union[DocumentOptions, Dict[str, Any]]] = N
44
45
  if options is None:
45
46
  opts = DocumentOptions()
46
47
  elif isinstance(options, dict):
48
+ # A VectorStore instance is read straight from the raw options by
49
+ # Document, so keep it out of the provider-config validation.
50
+ if isinstance(options.get("vectorStore"), VectorStore):
51
+ options = {k: v for k, v in options.items() if k != "vectorStore"}
47
52
  opts = DocumentOptions.model_validate(options)
48
53
  else:
49
54
  opts = options
@@ -5,7 +5,7 @@ try:
5
5
  PACKAGE_VERSION = importlib.metadata.version("raglite-toolkit")
6
6
  except importlib.metadata.PackageNotFoundError:
7
7
  PACKAGE_NAME = "raglite-toolkit"
8
- PACKAGE_VERSION = "1.2.0" # local development fallback
8
+ PACKAGE_VERSION = "1.2.1" # local development fallback
9
9
 
10
10
 
11
11
  SUPPORTED_EXTENSIONS = {".pdf", ".txt", ".json", ".md", ".markdown", ".docx"}
@@ -4,11 +4,12 @@ from pathlib import Path
4
4
  from typing import Any, Dict, Generator, List, Optional, Union
5
5
 
6
6
  from ..config import DocumentOptions, ResolvedConfig, resolve_config
7
- from ..errors import RagLiteError
7
+ from ..errors import ConfigError, RagLiteError
8
8
  from ..llm import generate_answer, stream_answer
9
9
  from ..loaders import DirectoryLoader, is_supported_file, is_url
10
10
  from ..types import AnswerResult, SearchResult
11
11
  from ..utils.logger import Logger, create_logger
12
+ from ..vectordb import VectorStore
12
13
  from .document import Document
13
14
 
14
15
 
@@ -91,6 +92,16 @@ class DocumentCollection:
91
92
  self.logger.warning(msg)
92
93
  errors.append({"source": src, "error": msg})
93
94
 
95
+ # A VectorStore instance has a single namespace, so every document would
96
+ # share it and each build() would reset the previous document's index.
97
+ raw_store = self.options.get("vectorStore") if isinstance(self.options, dict) else None
98
+ if isinstance(raw_store, VectorStore) and len(set(file_list)) > 1:
99
+ raise ConfigError(
100
+ "A VectorStore instance cannot be shared by multiple documents in a "
101
+ "DocumentCollection. Pass a vector store provider config "
102
+ '(e.g. {"provider": "qdrant", ...}) instead.'
103
+ )
104
+
94
105
  total_chunks = 0
95
106
  cached_docs = 0
96
107
  new_docs = 0
@@ -146,12 +157,19 @@ class DocumentCollection:
146
157
  st = score_threshold if score_threshold is not None else opts.get("scoreThreshold", opts.get("score_threshold", self.config.scoreThreshold))
147
158
 
148
159
  all_hits: List[SearchResult] = []
160
+ failures: List[Exception] = []
149
161
  for doc in self.documents.values():
150
162
  try:
151
163
  hits = doc.search(query, top_k=tk * 2, score_threshold=st)
152
164
  all_hits.extend(hits)
153
- except Exception:
154
- continue
165
+ except Exception as err:
166
+ failures.append(err)
167
+ self.logger.warning(f'Search failed for document "{doc.file_path}": {err}')
168
+
169
+ # One broken document should not hide results from the others, but if
170
+ # every document failed the caller needs the error, not an empty list.
171
+ if len(failures) == len(self.documents):
172
+ raise failures[0]
155
173
 
156
174
  all_hits.sort(key=lambda h: h.score, reverse=True)
157
175
  return all_hits[:tk]
@@ -10,7 +10,13 @@ from ..errors import FileNotIndexedError, LoaderError, RagLiteError
10
10
  from ..llm import generate_answer, stream_answer
11
11
  from ..loaders import get_loader, is_url
12
12
  from ..retrieval import Retriever
13
- from ..types import AnswerResult, ChunkMetadata, IndexMetadata, StoredChunk
13
+ from ..types import (
14
+ AnswerResult,
15
+ ChunkMetadata,
16
+ EmbeddingProviderConfig,
17
+ IndexMetadata,
18
+ StoredChunk,
19
+ )
14
20
  from ..utils.hash import hash_file, hash_string, namespace_from_path
15
21
  from ..utils.logger import create_logger
16
22
  from ..vectordb import MemoryVectorStore, VectorStore, create_vector_store
@@ -74,16 +80,17 @@ class Document:
74
80
  if should_rebuild is None:
75
81
  should_rebuild = opts.get("rebuild", False)
76
82
 
77
- if not os.path.exists(self.file_path):
83
+ if not is_url(self.file_path) and not os.path.exists(self.file_path):
78
84
  raise LoaderError(f"File does not exist: {self.file_path}")
79
85
 
80
86
  self.store.load()
81
87
  existing = self.store.read_index_metadata()
82
- source_hash = (
83
- hash_string(self.file_path)
84
- if is_url(self.file_path)
85
- else hash_file(self.file_path)
86
- )
88
+ # Web pages change without their URL changing, so fingerprint the
89
+ # fetched content rather than the URL.
90
+ text: Optional[str] = None
91
+ if is_url(self.file_path):
92
+ text = get_loader(self.file_path).load()
93
+ source_hash = hash_string(text) if text is not None else hash_file(self.file_path)
87
94
 
88
95
  if (
89
96
  not should_rebuild
@@ -110,8 +117,8 @@ class Document:
110
117
  self.store.reset()
111
118
  self.store.load()
112
119
 
113
- loader = get_loader(self.file_path)
114
- text = loader.load()
120
+ if text is None:
121
+ text = get_loader(self.file_path).load()
115
122
  if not text:
116
123
  raise LoaderError(
117
124
  f"Loader returned empty text for {self.file_path}"
@@ -340,19 +347,10 @@ class Document:
340
347
  f'No RagLite index found for "{self.file_path}". Call build() first.'
341
348
  )
342
349
 
343
- embed_config = self.config.embeddings
344
- if embed_config.model is None:
345
- from ..types import EmbeddingProviderConfig
346
-
347
- embed_config = EmbeddingProviderConfig(
348
- provider=embed_config.provider,
349
- model=existing.embeddingModel,
350
- apiKey=embed_config.apiKey,
351
- baseURL=embed_config.baseURL,
352
- )
353
-
354
350
  if self.embedder is None:
355
- self.embedder = create_embedder(embed_config)
351
+ self.embedder = create_embedder(
352
+ _query_embeddings_config(existing, self.config.embeddings)
353
+ )
356
354
  self.ready = True
357
355
 
358
356
  def _cache_still_valid(
@@ -378,3 +376,19 @@ class Document:
378
376
  if req_model is not None and req_model != existing.embeddingModel:
379
377
  return False
380
378
  return True
379
+
380
+
381
+ def _query_embeddings_config(
382
+ existing: IndexMetadata, configured: EmbeddingProviderConfig
383
+ ) -> EmbeddingProviderConfig:
384
+ """Queries must be embedded with the same provider and model as the index,
385
+ which may differ from the constructor default when ``build(embeddings=...)``
386
+ overrode it. Credentials from the configured provider are reused when it
387
+ matches; otherwise the provider falls back to its environment variables."""
388
+ same_provider = configured.provider == existing.embeddingProvider
389
+ return EmbeddingProviderConfig(
390
+ provider=existing.embeddingProvider,
391
+ model=existing.embeddingModel,
392
+ apiKey=configured.apiKey if same_provider else None,
393
+ baseURL=configured.baseURL if same_provider else None,
394
+ )
@@ -1,6 +1,7 @@
1
1
  import html
2
2
  import re
3
3
 
4
+ from ..constants import PACKAGE_VERSION
4
5
  from ..errors import LoaderError
5
6
  from .base import BaseLoader
6
7
 
@@ -18,7 +19,7 @@ class WebLoader(BaseLoader):
18
19
  import httpx
19
20
 
20
21
  headers = {
21
- "User-Agent": "RAGLite/1.2.0 (Python/3.10+)",
22
+ "User-Agent": f"RAGLite/{PACKAGE_VERSION} (Python/3.10+)",
22
23
  "Accept": "text/html,text/plain,application/xhtml+xml;q=0.9,*/*;q=0.8",
23
24
  }
24
25
  response = httpx.get(self.url, headers=headers, follow_redirects=True, timeout=15.0)
@@ -31,12 +31,22 @@ class QdrantVectorStore(VectorStore):
31
31
  self._url = url.rstrip("/")
32
32
  self._api_key = api_key
33
33
  self._collection = collection_name or f"raglite_{namespace}"
34
+ # With an explicit collection name, several namespaces may share one
35
+ # collection, so every operation is scoped to this namespace instead
36
+ # of touching the whole collection.
37
+ self._shared = collection_name is not None
38
+ self._metadata_id = (
39
+ _uuid_from_str(f"{namespace}__metadata__") if self._shared else _METADATA_UUID
40
+ )
34
41
  self._count_cache: int = 0
35
42
 
36
43
  @property
37
44
  def namespace(self) -> str:
38
45
  return self._namespace
39
46
 
47
+ def _namespace_filter(self) -> dict:
48
+ return {"must": [{"key": "namespace", "match": {"value": self._namespace}}]}
49
+
40
50
  def _headers(self) -> dict:
41
51
  h: dict = {"Content-Type": "application/json"}
42
52
  if self._api_key:
@@ -70,7 +80,14 @@ class QdrantVectorStore(VectorStore):
70
80
 
71
81
  def reset(self) -> None:
72
82
  try:
73
- self._request("DELETE", f"/collections/{self._collection}")
83
+ if self._shared:
84
+ self._request(
85
+ "POST",
86
+ f"/collections/{self._collection}/points/delete?wait=true",
87
+ {"filter": self._namespace_filter()},
88
+ )
89
+ else:
90
+ self._request("DELETE", f"/collections/{self._collection}")
74
91
  except VectorDBError:
75
92
  pass
76
93
  self._count_cache = 0
@@ -85,6 +102,7 @@ class QdrantVectorStore(VectorStore):
85
102
  "vector": c.embedding,
86
103
  "payload": {
87
104
  "id": c.id,
105
+ "namespace": self._namespace,
88
106
  "text": c.text,
89
107
  "metadata": c.metadata.model_dump(by_alias=True),
90
108
  "isMetadata": False,
@@ -96,15 +114,18 @@ class QdrantVectorStore(VectorStore):
96
114
  self._count_cache += len(chunks)
97
115
 
98
116
  def search(self, embedding: List[float], top_k: int) -> List[VectorSearchHit]:
117
+ body: dict = {
118
+ "vector": embedding,
119
+ "limit": top_k + 1,
120
+ "with_payload": True,
121
+ }
122
+ if self._shared:
123
+ body["filter"] = self._namespace_filter()
99
124
  try:
100
125
  data = self._request(
101
126
  "POST",
102
127
  f"/collections/{self._collection}/points/search",
103
- {
104
- "vector": embedding,
105
- "limit": top_k + 1,
106
- "with_payload": True,
107
- },
128
+ body,
108
129
  )
109
130
  except VectorDBError:
110
131
  return []
@@ -112,7 +133,7 @@ class QdrantVectorStore(VectorStore):
112
133
  hits: List[VectorSearchHit] = []
113
134
  for r in data.get("result", []):
114
135
  payload = r.get("payload", {}) or {}
115
- if r.get("id") == _METADATA_UUID or payload.get("isMetadata"):
136
+ if r.get("id") == self._metadata_id or payload.get("isMetadata"):
116
137
  continue
117
138
  raw_meta = payload.get("metadata") or {}
118
139
  metadata = ChunkMetadata(
@@ -141,13 +162,14 @@ class QdrantVectorStore(VectorStore):
141
162
  zero_vec = [0.0] * dim
142
163
  payload_data = metadata.model_dump(by_alias=True)
143
164
  payload_data["isMetadata"] = True
165
+ payload_data["namespace"] = self._namespace
144
166
  self._request(
145
167
  "PUT",
146
168
  f"/collections/{self._collection}/points",
147
169
  {
148
170
  "points": [
149
171
  {
150
- "id": _METADATA_UUID,
172
+ "id": self._metadata_id,
151
173
  "vector": zero_vec,
152
174
  "payload": payload_data,
153
175
  }
@@ -161,7 +183,7 @@ class QdrantVectorStore(VectorStore):
161
183
  data = self._request(
162
184
  "POST",
163
185
  f"/collections/{self._collection}/points",
164
- {"ids": [_METADATA_UUID], "with_payload": True},
186
+ {"ids": [self._metadata_id], "with_payload": True},
165
187
  )
166
188
  except VectorDBError:
167
189
  return None
@@ -0,0 +1,210 @@
1
+ """
2
+ Regression tests for URL indexing and shared vector stores across documents.
3
+ """
4
+ import shutil
5
+ import tempfile
6
+ from pathlib import Path
7
+ from unittest.mock import patch
8
+
9
+ import pytest
10
+
11
+ from raglite.core.collection import DocumentCollection
12
+ from raglite.core.document import Document
13
+ from raglite.embeddings.local import LocalEmbedder
14
+ from raglite.errors import ConfigError
15
+ from raglite.types import ChunkMetadata, EmbeddingProviderConfig, StoredChunk
16
+ from raglite.vectordb import MemoryVectorStore
17
+ from raglite.vectordb.qdrant import QdrantVectorStore
18
+
19
+
20
+ def unit_vec_384():
21
+ return [1.0] + [0.0] * 383
22
+
23
+
24
+ @pytest.fixture
25
+ def tmp_dir():
26
+ d = tempfile.mkdtemp(prefix="raglite_regress_")
27
+ yield d
28
+ shutil.rmtree(d, ignore_errors=True)
29
+
30
+
31
+ def test_document_builds_from_url(tmp_dir):
32
+ with patch(
33
+ "raglite.loaders.web.WebLoader.load", return_value="Refunds are issued within 30 days."
34
+ ), patch.object(
35
+ LocalEmbedder,
36
+ "embed_documents",
37
+ side_effect=lambda texts: [unit_vec_384() for _ in texts],
38
+ ):
39
+ doc = Document("https://example.com/policy", {"storeDir": tmp_dir, "logLevel": "silent"})
40
+ result = doc.build()
41
+
42
+ assert result["chunkCount"] == 1
43
+ assert result["cached"] is False
44
+
45
+
46
+ def test_collection_rejects_shared_store_instance(tmp_dir):
47
+ (Path(tmp_dir) / "a.txt").write_text("first document", encoding="utf-8")
48
+ (Path(tmp_dir) / "b.txt").write_text("second document", encoding="utf-8")
49
+ store = MemoryVectorStore(str(Path(tmp_dir) / ".raglite"), "shared")
50
+
51
+ collection = DocumentCollection(tmp_dir, {"vectorStore": store, "logLevel": "silent"})
52
+ with pytest.raises(ConfigError):
53
+ collection.build()
54
+
55
+
56
+ def test_qdrant_shared_collection_is_scoped_to_namespace():
57
+ calls = []
58
+
59
+ def fake_request(self, method, path, body=None):
60
+ calls.append((self.namespace, method, path, body))
61
+ return {"result": []}
62
+
63
+ with patch.object(QdrantVectorStore, "_request", fake_request):
64
+ a = QdrantVectorStore("http://qdrant:6333", "ns-a", collection_name="shared")
65
+ b = QdrantVectorStore("http://qdrant:6333", "ns-b", collection_name="shared")
66
+
67
+ a.reset()
68
+ a.add(
69
+ [
70
+ StoredChunk(
71
+ id="ns-a_000001",
72
+ text="hello",
73
+ embedding=[1.0, 0.0, 0.0],
74
+ metadata=ChunkMetadata(source="a.txt", chunk=1, totalChunks=1),
75
+ )
76
+ ]
77
+ )
78
+ a.search([1.0, 0.0, 0.0], 3)
79
+ a.read_index_metadata()
80
+ b.read_index_metadata()
81
+
82
+ assert not any(method == "DELETE" for _, method, _, _ in calls)
83
+
84
+ delete = next(c for c in calls if c[2].endswith("/points/delete?wait=true"))
85
+ assert delete[3]["filter"]["must"][0]["match"]["value"] == "ns-a"
86
+
87
+ upsert = next(c for c in calls if c[1] == "PUT" and c[2].endswith("/points"))
88
+ assert upsert[3]["points"][0]["payload"]["namespace"] == "ns-a"
89
+
90
+ search = next(c for c in calls if c[2].endswith("/points/search"))
91
+ assert search[3]["filter"]["must"][0]["match"]["value"] == "ns-a"
92
+
93
+ meta_ids = [c[3]["ids"][0] for c in calls if c[1] == "POST" and c[2] == "/collections/shared/points"]
94
+ assert len(meta_ids) == 2
95
+ assert meta_ids[0] != meta_ids[1]
96
+
97
+
98
+ def test_qdrant_dedicated_collection_keeps_existing_behaviour():
99
+ calls = []
100
+
101
+ def fake_request(self, method, path, body=None):
102
+ calls.append((method, path, body))
103
+ return {"result": []}
104
+
105
+ with patch.object(QdrantVectorStore, "_request", fake_request):
106
+ store = QdrantVectorStore("http://qdrant:6333", "ns-a")
107
+ store.reset()
108
+ store.search([1.0, 0.0, 0.0], 3)
109
+
110
+ assert ("DELETE", "/collections/raglite_ns-a", None) in calls
111
+ search = next(c for c in calls if c[1].endswith("/points/search"))
112
+ assert "filter" not in search[2]
113
+
114
+
115
+ class FakeEmbedder:
116
+ def __init__(self, config):
117
+ self.provider = config.provider
118
+ self.model = config.model or "fake-model"
119
+ self.dimensions = 384
120
+
121
+ def embed_documents(self, texts):
122
+ return [unit_vec_384() for _ in texts]
123
+
124
+ def embed_query(self, text):
125
+ return unit_vec_384()
126
+
127
+
128
+ @pytest.fixture
129
+ def embedder_configs():
130
+ configs = []
131
+
132
+ def fake_create(config):
133
+ configs.append(config)
134
+ return FakeEmbedder(config)
135
+
136
+ with patch("raglite.core.document.create_embedder", side_effect=fake_create):
137
+ yield configs
138
+
139
+
140
+ def test_url_reindexes_when_page_content_changes(tmp_dir, embedder_configs):
141
+ page = {"text": "Refunds are issued within 30 days."}
142
+ opts = {"storeDir": tmp_dir, "logLevel": "silent"}
143
+ url = "https://example.com/policy"
144
+
145
+ with patch("raglite.loaders.web.WebLoader.load", side_effect=lambda: page["text"]):
146
+ assert Document(url, opts).build()["cached"] is False
147
+ assert Document(url, opts).build()["cached"] is True
148
+ page["text"] = "Refunds are issued within 60 days."
149
+ assert Document(url, opts).build()["cached"] is False
150
+
151
+
152
+ def test_query_embedder_uses_index_provider_after_reload(tmp_dir, embedder_configs):
153
+ path = Path(tmp_dir) / "policy.txt"
154
+ path.write_text("Refunds are issued within 30 days.", encoding="utf-8")
155
+ opts = {"storeDir": tmp_dir, "logLevel": "silent"}
156
+
157
+ Document(str(path), opts).build(
158
+ embeddings=EmbeddingProviderConfig(provider="openai", model="text-embedding-3-small")
159
+ )
160
+ embedder_configs.clear()
161
+ Document(str(path), opts).search("refund")
162
+
163
+ assert len(embedder_configs) == 1
164
+ assert embedder_configs[0].provider == "openai"
165
+ assert embedder_configs[0].model == "text-embedding-3-small"
166
+ assert embedder_configs[0].apiKey is None
167
+
168
+
169
+ def test_query_embedder_reuses_credentials_for_same_provider(tmp_dir, embedder_configs):
170
+ path = Path(tmp_dir) / "policy.txt"
171
+ path.write_text("Refunds are issued within 30 days.", encoding="utf-8")
172
+ opts = {
173
+ "storeDir": tmp_dir,
174
+ "logLevel": "silent",
175
+ "embeddings": {"provider": "openai", "apiKey": "sk-test"},
176
+ }
177
+
178
+ Document(str(path), opts).build()
179
+ embedder_configs.clear()
180
+ Document(str(path), opts).search("refund")
181
+
182
+ assert embedder_configs[0].apiKey == "sk-test"
183
+
184
+
185
+ def _two_doc_collection(tmp_dir):
186
+ (Path(tmp_dir) / "policy.txt").write_text("Refunds within 30 days.", encoding="utf-8")
187
+ (Path(tmp_dir) / "shipping.txt").write_text("Delivery takes 2 days.", encoding="utf-8")
188
+ collection = DocumentCollection(
189
+ tmp_dir, {"storeDir": str(Path(tmp_dir) / ".raglite"), "logLevel": "silent"}
190
+ )
191
+ collection.build()
192
+ return collection
193
+
194
+
195
+ def test_collection_search_survives_one_failing_document(tmp_dir, embedder_configs):
196
+ collection = _two_doc_collection(tmp_dir)
197
+ broken = next(iter(collection.documents.values()))
198
+ with patch.object(broken, "search", side_effect=RuntimeError("boom")):
199
+ hits = collection.search("refund", top_k=5)
200
+ assert len(hits) == 1
201
+
202
+
203
+ def test_collection_search_raises_when_every_document_fails(tmp_dir, embedder_configs):
204
+ collection = _two_doc_collection(tmp_dir)
205
+ docs = list(collection.documents.values())
206
+ with patch.object(docs[0], "search", side_effect=RuntimeError("bad api key")), patch.object(
207
+ docs[1], "search", side_effect=RuntimeError("bad api key")
208
+ ):
209
+ with pytest.raises(RuntimeError, match="bad api key"):
210
+ collection.search("refund")
File without changes