Skip to content

Commit f79e0a6

Browse files
rmitschKabir KhankoaningshadeMesvlandeg
authored
Use prompt template to ensure data consistency in cache directory (#192)
* Fix cache and nlp.pipe(..., as_tuples=True) (#188) * ensure doc from cache has the same context as the passed in doc. Needed for nlp.pipe(egs, as_tuples=True) * add test for as_tuples and nlp.pipe * Update README.md (#189) * Update README.md (#190) * Update README.md * Update README.md * Update README.md --------- Co-authored-by: Raphael Mitsch <r.mitsch@outlook.com> * Fix issue of last cached element in batch not having IO data (#191) * Fix issue of last cached element in batch not having IO data. * Minor formatting change. * Bump version. * Use prompt template to ensure data consistency in cache directory. * Fix invalid config test. * Fix remaining test tasks without prompt_template(). * Apply suggestions from code review Co-authored-by: Madeesh Kannan <shadeMe@users.noreply.github.com> * Fix formatting issues. * Replace normalization with hash. * Add Cache.initialize(). * Update README.md Co-authored-by: Sofie Van Landeghem <svlandeg@users.noreply.github.com> * Move prompt template availability to different protocol. Extend tests. * Add section on prompt_template() in readme. * Update spacy_llm/cache.py Co-authored-by: Sofie Van Landeghem <svlandeg@users.noreply.github.com> --------- Co-authored-by: Kabir Khan <kabir@explosion.ai> Co-authored-by: vincent d warmerdam <vincentwarmerdam@gmail.com> Co-authored-by: Madeesh Kannan <shadeMe@users.noreply.github.com> Co-authored-by: svlandeg <svlandeg@github.com> Co-authored-by: Sofie Van Landeghem <svlandeg@users.noreply.github.com>
1 parent e2dc3c7 commit f79e0a6

12 files changed

Lines changed: 229 additions & 35 deletions

File tree

README.md

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1474,23 +1474,37 @@ The default `query` (`spacy.CallLangChain.v1`) executes the prompts by running `
14741474

14751475
Interacting with LLMs, either through an external API or a local instance, is costly.
14761476
Since developing an NLP pipeline generally means a lot of exploration and prototyping,
1477-
`spacy-llm` implements a built-in cache to avoid reprocessing the same documents at each run.
1477+
`spacy-llm` implements a built-in cache to avoid reprocessing the same documents at each run
1478+
that keeps batches of documents stored on disk.
1479+
1480+
The cache implementation also ensures that documents in one cache directory were all produced using the same prompt
1481+
template. This is only possible however if the specified task implements
1482+
```python
1483+
@property
1484+
def prompt_template() -> str:
1485+
...
1486+
```
1487+
which returns the raw prompt template as string. If `prompt_template()` isn't implemented, the cache will emit a warning
1488+
and not check for prompt template consistency.
14781489

14791490
Example config block:
14801491

14811492
```ini
14821493
[components.llm.cache]
1483-
@llm_misc = "spacy.BatchCache.v1",
1494+
@llm_misc = "spacy.BatchCache.v1"
14841495
path = "path/to/cache"
14851496
batch_size = 64
14861497
max_batches_in_mem = 4
14871498
```
14881499

1489-
| Argument | Type | Default | Description |
1490-
| -------------------- | ---------------------------- | ------- | ---------------------------------------------------- |
1491-
| `path` | `Optional[Union[str, Path]]` | `None` | Cache directory. If `None`, no caching is performed. |
1492-
| `batch_size` | `int` | 64 | Number of docs in one batch (file). |
1493-
| `max_batches_in_mem` | `int` | 4 | Max. number of batches to hold in memory. |
1500+
| Argument | Type | Default | Description |
1501+
| -------------------- | ---------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------- |
1502+
| `path` | `Optional[Union[str, Path]]` | `None` | Cache directory. If `None`, no caching is performed, and this component will act as a NoOp. |
1503+
| `batch_size` | `int` | 64 | Number of docs in one batch (file). Once a batch is full, it will be persisted to disk. |
1504+
| `max_batches_in_mem` | `int` | 4 | Max. number of batches to hold in memory. Allows you to limit the effect on your memory if you're handling a lot of docs. |
1505+
1506+
When retrieving a document, the `BatchCache` will first figure out what batch the document belongs to. If the batch
1507+
isn't in memory it will try to load the batch from disk and then move it into memory.
14941508

14951509
Note that since the cache is generated by a registered function, you can also provide your own registered function
14961510
returning your own cache implementation. If you wish to do so, ensure that your cache object adheres to the

spacy_llm/cache.py

Lines changed: 64 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import warnings
12
from pathlib import Path
23
from typing import Dict, Iterable, List, Optional, Union
34

@@ -7,6 +8,7 @@
78
from spacy.vocab import Vocab
89

910
from .registry import registry
11+
from .ty import LLMTask, PromptTemplateProvider
1012

1113

1214
@registry.llm_misc("spacy.BatchCache.v1")
@@ -31,7 +33,7 @@ def __init__(
3133
batch_size: int,
3234
max_batches_in_mem: int,
3335
):
34-
"""Initialize Cache instance.
36+
"""Initialize Cache instance. Acts as NoOp if path is None.
3537
path (Optional[Union[str,Path]]): Cache directory.
3638
batch_size (int): Number of docs in one batch (file).
3739
max_batches_in_mem (int): Max. number of batches to hold in memory.
@@ -43,6 +45,8 @@ def __init__(
4345
# Max. number of batches to keep in memory.
4446
self.max_batches_in_mem = max_batches_in_mem
4547
self._vocab: Optional[Vocab] = None
48+
self._prompt_template: Optional[str] = None
49+
self._prompt_template_checked: bool = False
4650

4751
# Stores doc hash -> batch hash to allow efficient lookup of available Docs.
4852
self._doc2batch: Dict[int, int] = {}
@@ -62,31 +66,71 @@ def __init__(
6266
"persisted": 0,
6367
}
6468

65-
self._init_cache_index()
69+
self._init_cache_dir()
70+
71+
def initialize(self, vocab: Vocab, task: LLMTask) -> None:
72+
"""
73+
Initialize cache with data not available at construction time.
74+
vocab (Vocab): Vocab object.
75+
task (LLMTask): Task.
76+
"""
77+
self._vocab = vocab
78+
if isinstance(task, PromptTemplateProvider):
79+
self.prompt_template = task.prompt_template
80+
else:
81+
self.prompt_template = ""
82+
if self._path:
83+
warnings.warn(
84+
"The specified task does not provide its prompt template via `prompt_template()`. This means that "
85+
"the cache cannot verify whether all cached documents were generated using the same prompt "
86+
"template."
87+
)
6688

6789
@property
68-
def vocab(self) -> Optional[Vocab]:
69-
"""Vocab used for deserializing docs.
70-
RETURNS (Vocab): Vocab used for deserializing docs.
90+
def prompt_template(self) -> Optional[str]:
91+
"""Get prompt template.
92+
RETURNS (Optional[str]): Prompt template string used for docs to cache/cached docs.
7193
"""
72-
return self._vocab
94+
return self._prompt_template
7395

74-
@vocab.setter
75-
def vocab(self, vocab: Vocab) -> None:
76-
"""Set vocab to use for deserializing docs.
77-
vocab (Vocab): Vocab to use for deserializing docs.
96+
@prompt_template.setter
97+
def prompt_template(self, prompt_template: str) -> None:
98+
"""Set prompt template.
99+
prompt_template (str): Prompt template string used for docs to cache/cached docs.
78100
"""
79-
self._vocab = vocab
101+
self._prompt_template = prompt_template
102+
if not self._path:
103+
return
104+
105+
# Store prompt template as file.
106+
prompt_template_path = self._path / "prompt_template.txt"
107+
# If no file exists: store in plain text for easier debugging.
108+
if not prompt_template_path.exists():
109+
with open(prompt_template_path, "w") as file:
110+
file.write(self._prompt_template)
111+
112+
else:
113+
# If file exists and prompt template is not equal to new prompt template: raise, as this indicates we are
114+
# trying to reuse a cache directory with a changed prompt.
115+
with open(prompt_template_path, "r") as file:
116+
existing_prompt_template = "".join(file.readlines())
117+
if hash(existing_prompt_template) != hash(self._prompt_template):
118+
raise ValueError(
119+
f"Prompt template in cache directory ({prompt_template_path}) is not equal with "
120+
f"current prompt template. Reset your cache if you are using a new prompt "
121+
f"template."
122+
)
80123

81-
def _init_cache_index(self) -> None:
82-
"""Init cache index and directory."""
124+
def _init_cache_dir(self) -> None:
125+
"""Init cache directory with index and prompt template file."""
83126
if self._path is None:
84127
return
85128

86129
if self._path.exists() and not self._path.is_dir():
87130
raise ValueError("Cache directory exists and is not a directory.")
88131
self._path.mkdir(parents=True, exist_ok=True)
89132

133+
# Read from index file, if it exists.
90134
index_path = self._index_path
91135
if index_path.exists():
92136
for rec in srsly.read_jsonl(index_path):
@@ -136,6 +180,13 @@ def add(self, doc: Doc) -> None:
136180
"""
137181
if self._path is None:
138182
return
183+
if not self._prompt_template_checked and self._prompt_template is None:
184+
warnings.warn(
185+
"No prompt template set for Cache object, entailing that consistency of prompt template used "
186+
"to generate docs cannot be checked. Be mindful to reset your cache whenever you change your "
187+
"prompt template."
188+
)
189+
self._prompt_template_checked = True
139190

140191
self._cache_queue.append(doc)
141192
self._stats["added"] += 1

spacy_llm/pipeline/llm.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,8 @@ def __init__(
116116
self._task = task
117117
self._model = model
118118
self._cache = cache
119-
self._cache.vocab = vocab
120119
self._save_io = save_io
120+
self._cache.initialize(vocab, self._task)
121121

122122
# This is done this way because spaCy's `validate_init_settings` function
123123
# does not support `**kwargs: Any`.
@@ -212,11 +212,10 @@ def _process_docs(self, docs: List[Doc]) -> List[Doc]:
212212
if is_cached[i]:
213213
cached_doc = self._cache[doc]
214214
assert cached_doc is not None
215+
cached_doc._context = doc._context
215216
final_docs.append(cached_doc)
216217
else:
217218
doc = next(modified_docs)
218-
self._cache.add(doc)
219-
final_docs.append(doc)
220219

221220
if self._save_io:
222221
# Make sure the `llm_io` field is set
@@ -227,6 +226,9 @@ def _process_docs(self, docs: List[Doc]) -> List[Doc]:
227226
llm_io["prompt"] = str(next(prompts_iters[2]))
228227
llm_io["response"] = str(next(responses_iters[2]))
229228

229+
self._cache.add(doc)
230+
final_docs.append(doc)
231+
230232
return final_docs
231233

232234
def to_bytes(

spacy_llm/tasks/lemma.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ def initialize(
7474
if n_prompt_examples < 0 or len(self._prompt_examples) < n_prompt_examples:
7575
self._prompt_examples.append(self._create_prompt_example(eg))
7676

77+
@property
78+
def prompt_template(self) -> str:
79+
return self._template
80+
7781
def generate_prompts(self, docs: Iterable[Doc]) -> Iterable[str]:
7882
environment = jinja2.Environment()
7983
_template = environment.from_string(self._template)

spacy_llm/tasks/noop.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,10 @@ def parse_responses(
2222
) -> Iterable[Doc]:
2323
# Not doing anything
2424
return docs
25+
26+
@property
27+
def prompt_template(self) -> str:
28+
return """
29+
This is the NoOp
30+
prompt template
31+
"""

spacy_llm/tasks/rel.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,10 @@ def _check_rel_extension(cls):
143143
def labels(self) -> Tuple[str, ...]:
144144
return tuple(self._label_dict.values())
145145

146+
@property
147+
def prompt_template(self) -> str:
148+
return self._template
149+
146150
def generate_prompts(self, docs: Iterable[Doc]) -> Iterable[str]:
147151
environment = jinja2.Environment()
148152
_template = environment.from_string(self._template)

spacy_llm/tasks/span.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@ def _check_label_consistency(self) -> List[SpanExample]:
9090
def labels(self) -> Tuple[str, ...]:
9191
return tuple(self._label_dict.values())
9292

93+
@property
94+
def prompt_template(self) -> str:
95+
return self._template
96+
9397
def generate_prompts(self, docs: Iterable[Doc]) -> Iterable[str]:
9498
environment = jinja2.Environment()
9599
_template = environment.from_string(self._template)

spacy_llm/tasks/textcat.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,10 @@ def __init__(
255255
def labels(self) -> Tuple[str, ...]:
256256
return tuple(self._label_dict.values())
257257

258+
@property
259+
def prompt_template(self) -> str:
260+
return self._template
261+
258262
def generate_prompts(self, docs: Iterable[Doc]) -> Iterable[str]:
259263
environment = jinja2.Environment()
260264
_template = environment.from_string(self._template)

spacy_llm/tests/models/test_rest.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,21 @@
2121

2222
@registry.llm_tasks("spacy.Count.v1")
2323
class _CountTask:
24+
_PROMPT_TEMPLATE = "Count the number of characters in this string: '{text}'."
25+
2426
def generate_prompts(self, docs: Iterable[Doc]) -> Iterable[str]:
2527
for doc in docs:
26-
yield f"Count the number of characters in this string: '{doc.text}'."
28+
yield _CountTask._PROMPT_TEMPLATE.format(text=doc.text)
2729

2830
def parse_responses(
2931
self, docs: Iterable[Doc], responses: Iterable[str]
3032
) -> Iterable[Doc]:
3133
return docs
3234

35+
@property
36+
def prompt_template(self) -> str:
37+
return _CountTask._PROMPT_TEMPLATE
38+
3339

3440
def test_initialization():
3541
"""Test initialization and simple run"""

spacy_llm/tests/pipeline/test_llm.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,11 @@ def test_llm_pipe_with_cache(tmp_path: Path, n_process: int):
101101
docs = list(nlp.pipe(texts=texts, n_process=n_process))
102102
assert [doc.text for doc in docs] == texts
103103

104+
egs = [(text, i) for i, text in enumerate(texts)]
105+
egs_processed = list(nlp.pipe(egs, as_tuples=True, n_process=n_process))
106+
assert [doc.text for doc, _ in egs_processed] == texts
107+
assert [eg for _, eg in egs_processed] == list(range(len(texts)))
108+
104109

105110
def test_llm_pipe_empty(nlp):
106111
"""Test call .pipe() with empty batch."""

0 commit comments

Comments
 (0)