fromscratch-pysearch 0.3.0__py3-none-any.whl
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.
- fromscratch_pysearch-0.3.0.dist-info/METADATA +188 -0
- fromscratch_pysearch-0.3.0.dist-info/RECORD +15 -0
- fromscratch_pysearch-0.3.0.dist-info/WHEEL +4 -0
- fromscratch_pysearch-0.3.0.dist-info/licenses/LICENSE +21 -0
- pysearch/__init__.py +1 -0
- pysearch/_version.py +24 -0
- pysearch/atomic.py +66 -0
- pysearch/engine.py +162 -0
- pysearch/errors.py +22 -0
- pysearch/index.py +127 -0
- pysearch/persistence.py +65 -0
- pysearch/py.typed +0 -0
- pysearch/query.py +118 -0
- pysearch/tfidf.py +94 -0
- pysearch/tokenizer.py +96 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: fromscratch-pysearch
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: An educational full-text search engine built from scratch in Python
|
|
5
|
+
Project-URL: Homepage, https://github.com/robcox2910/pysearch
|
|
6
|
+
Project-URL: Repository, https://github.com/robcox2910/pysearch
|
|
7
|
+
Project-URL: Issues, https://github.com/robcox2910/pysearch/issues
|
|
8
|
+
Project-URL: Documentation, https://robcox2910.github.io/pysearch/
|
|
9
|
+
Author: Rob Cox
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: education,inverted-index,search,tfidf
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Education
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Education
|
|
19
|
+
Classifier: Topic :: Text Processing :: Indexing
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.14
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: commitizen>=4.4.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: hypothesis>=6.170; extra == 'dev'
|
|
25
|
+
Requires-Dist: pip-audit>=2.10.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: pre-commit>=4.0.1; extra == 'dev'
|
|
27
|
+
Requires-Dist: pyright>=1.1.400; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-cov>=6.0.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=8.3.4; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff>=0.9.3; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# PySearch
|
|
34
|
+
|
|
35
|
+
**An educational full-text search engine built from scratch in Python.**
|
|
36
|
+
|
|
37
|
+
## The Librarian Who Read Every Book
|
|
38
|
+
|
|
39
|
+
Imagine a librarian who has read every single book in the library. When you ask
|
|
40
|
+
her "Which books mention *dinosaurs*?", she doesn't need to open a single book.
|
|
41
|
+
She already knows the answer because she kept notes while reading.
|
|
42
|
+
|
|
43
|
+
For every word she encountered, she wrote down which book it was in and how many
|
|
44
|
+
times it appeared. Her notebook is called an **inverted index**, and it lets her
|
|
45
|
+
answer your question in the blink of an eye.
|
|
46
|
+
|
|
47
|
+
But what if ten books mention dinosaurs? Which one should you read first? The
|
|
48
|
+
librarian uses a clever trick called **TF-IDF** -- she ranks the books so the
|
|
49
|
+
ones that talk about dinosaurs *the most* (and about other things *the least*)
|
|
50
|
+
float to the top.
|
|
51
|
+
|
|
52
|
+
PySearch teaches you how to build this librarian from scratch.
|
|
53
|
+
|
|
54
|
+
## Examples
|
|
55
|
+
|
|
56
|
+
### Adding Documents and Searching
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from pysearch.engine import SearchEngine
|
|
60
|
+
|
|
61
|
+
# Create a search engine and add some documents
|
|
62
|
+
engine = SearchEngine()
|
|
63
|
+
engine.add("doc1", "Python is a great programming language")
|
|
64
|
+
engine.add("doc2", "Java is also a popular language")
|
|
65
|
+
engine.add("doc3", "Python and Java are both used for web development")
|
|
66
|
+
|
|
67
|
+
# Search for a single term -- results ranked by relevance
|
|
68
|
+
results = engine.search("python")
|
|
69
|
+
for doc_id, score in results:
|
|
70
|
+
print(f"{doc_id}: {score:.4f}")
|
|
71
|
+
|
|
72
|
+
# Boolean queries
|
|
73
|
+
results = engine.search("python AND java") # documents with both
|
|
74
|
+
results = engine.search("python OR java") # documents with either
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Using the Tokenizer Directly
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from pysearch.tokenizer import tokenize, simple_stem
|
|
81
|
+
|
|
82
|
+
tokens = tokenize("The quick brown foxes are running!")
|
|
83
|
+
print(tokens) # ['quick', 'brown', 'fox', 'runn']
|
|
84
|
+
|
|
85
|
+
print(simple_stem("running")) # 'runn'
|
|
86
|
+
print(simple_stem("played")) # 'play'
|
|
87
|
+
print(simple_stem("quickly")) # 'quick'
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Building an Inverted Index
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from pysearch.index import InvertedIndex
|
|
94
|
+
|
|
95
|
+
index = InvertedIndex()
|
|
96
|
+
index.add_document("doc1", "cats and dogs")
|
|
97
|
+
index.add_document("doc2", "cats and birds")
|
|
98
|
+
index.add_document("doc3", "dogs and birds")
|
|
99
|
+
|
|
100
|
+
print(index.search("cat")) # ['doc1', 'doc2']
|
|
101
|
+
print(index.search_and(["cat", "dog"])) # ['doc1']
|
|
102
|
+
print(index.search_or(["cat", "dog"])) # ['doc1', 'doc2', 'doc3']
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Features
|
|
106
|
+
|
|
107
|
+
- **Tokenizer** -- Split text into searchable words, remove stop words, and
|
|
108
|
+
apply simple stemming
|
|
109
|
+
- **Inverted Index** -- Map every word to the documents that contain it for
|
|
110
|
+
instant lookups
|
|
111
|
+
- **TF-IDF Ranking** -- Score and rank results so the most relevant documents
|
|
112
|
+
come first
|
|
113
|
+
- **Boolean Queries** -- Combine search terms with AND and OR operators
|
|
114
|
+
- **100% Typed** -- Full type annotations with strict Pyright checking
|
|
115
|
+
|
|
116
|
+
## Quick Start
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
# Install from source
|
|
120
|
+
git clone https://github.com/robcox2910/pysearch.git
|
|
121
|
+
cd pysearch
|
|
122
|
+
uv sync --all-extras
|
|
123
|
+
|
|
124
|
+
# Run the tests
|
|
125
|
+
uv run pytest
|
|
126
|
+
|
|
127
|
+
# Try it in a Python shell
|
|
128
|
+
uv run python -c "
|
|
129
|
+
from pysearch.engine import SearchEngine
|
|
130
|
+
engine = SearchEngine()
|
|
131
|
+
engine.add('doc1', 'hello world')
|
|
132
|
+
print(engine.search('hello'))
|
|
133
|
+
"
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Concepts
|
|
137
|
+
|
|
138
|
+
Saving an engine to disk and loading it back: [Saving Your Index](docs/concepts/saving.md).
|
|
139
|
+
|
|
140
|
+
<!-- docs:start -->
|
|
141
|
+
## Documentation
|
|
142
|
+
|
|
143
|
+
Full docs (with kid-friendly explanations of every idea): https://robcox2910.github.io/pysearch/
|
|
144
|
+
<!-- docs:end -->
|
|
145
|
+
|
|
146
|
+
## Releasing
|
|
147
|
+
|
|
148
|
+
Other projects in the series ([PyStack](https://github.com/robcox2910/pystack)) use a pinned release of this one,
|
|
149
|
+
not whatever is on `main`. To publish a change for them:
|
|
150
|
+
|
|
151
|
+
1. Merge the change to `main`.
|
|
152
|
+
2. Tag it: `git tag -a vX.Y.Z -m vX.Y.Z && git push origin vX.Y.Z`.
|
|
153
|
+
A change to the public API bumps the minor version (`v0.1.0` → `v0.2.0`);
|
|
154
|
+
anything else bumps the patch (`v0.1.0` → `v0.1.1`).
|
|
155
|
+
3. In each project that uses it, edit the tag in `[tool.uv.sources]`, run
|
|
156
|
+
`uv lock --upgrade-package <name>`, and open a pull request so CI checks
|
|
157
|
+
the pair together.
|
|
158
|
+
|
|
159
|
+
<!-- series:start -->
|
|
160
|
+
## Related Projects
|
|
161
|
+
|
|
162
|
+
This project is one of a series that builds real systems "from scratch in Python" to teach how they work. See the [series home](https://robcox2910.github.io/) for the full story.
|
|
163
|
+
|
|
164
|
+
| Project | What it teaches | Repository | Docs |
|
|
165
|
+
| --- | --- | --- | --- |
|
|
166
|
+
| PyOS | Operating systems | [py-os](https://github.com/robcox2910/py-os) | [Docs](https://robcox2910.github.io/py-os/) |
|
|
167
|
+
| Pebble | Compilers and programming languages | [pebble-lang](https://github.com/robcox2910/pebble-lang) | [Docs](https://robcox2910.github.io/pebble-lang/) |
|
|
168
|
+
| PyDB | Relational databases | [pydb](https://github.com/robcox2910/pydb) | [Docs](https://robcox2910.github.io/pydb/) |
|
|
169
|
+
| PyKV | Key-value stores | [pykv](https://github.com/robcox2910/pykv) | [Docs](https://robcox2910.github.io/pykv/) |
|
|
170
|
+
| PyDocDB | Document databases | [pydoc-db](https://github.com/robcox2910/pydoc-db) | [Docs](https://robcox2910.github.io/pydoc-db/) |
|
|
171
|
+
| PyGraphDB | Graph databases | [pygraphdb](https://github.com/robcox2910/pygraphdb) | [Docs](https://robcox2910.github.io/pygraphdb/) |
|
|
172
|
+
| PyTSDB | Time-series databases | [pytsdb](https://github.com/robcox2910/pytsdb) | [Docs](https://robcox2910.github.io/pytsdb/) |
|
|
173
|
+
| PyVecDB | Vector databases | [pyvecdb](https://github.com/robcox2910/pyvecdb) | [Docs](https://robcox2910.github.io/pyvecdb/) |
|
|
174
|
+
| PyColDB | Column-family databases | [pycoldb](https://github.com/robcox2910/pycoldb) | [Docs](https://robcox2910.github.io/pycoldb/) |
|
|
175
|
+
| PyCrypt | Cryptography | [pycrypt](https://github.com/robcox2910/pycrypt) | [Docs](https://robcox2910.github.io/pycrypt/) |
|
|
176
|
+
| PyNet | Computer networking | [pynet](https://github.com/robcox2910/pynet) | [Docs](https://robcox2910.github.io/pynet/) |
|
|
177
|
+
| PyWeb | HTTP web servers | [pyweb](https://github.com/robcox2910/pyweb) | [Docs](https://robcox2910.github.io/pyweb/) |
|
|
178
|
+
| PyGit | Version control | [pygit](https://github.com/robcox2910/pygit) | [Docs](https://robcox2910.github.io/pygit/) |
|
|
179
|
+
| **PySearch** | **Search engines** | [pysearch](https://github.com/robcox2910/pysearch) | [Docs](https://robcox2910.github.io/pysearch/) |
|
|
180
|
+
| PyMQ | Message queues | [pymq](https://github.com/robcox2910/pymq) | [Docs](https://robcox2910.github.io/pymq/) |
|
|
181
|
+
| PyLLM | Language models (the brain) | [pyllm](https://github.com/robcox2910/pyllm) | [Docs](https://robcox2910.github.io/pyllm/) |
|
|
182
|
+
| PyScheduler | Job scheduling | [pyscheduler](https://github.com/robcox2910/pyscheduler) | [Docs](https://robcox2910.github.io/pyscheduler/) |
|
|
183
|
+
| PyStack | Full-stack integration | [pystack](https://github.com/robcox2910/pystack) | [Docs](https://robcox2910.github.io/pystack/) |
|
|
184
|
+
<!-- series:end -->
|
|
185
|
+
|
|
186
|
+
## License
|
|
187
|
+
|
|
188
|
+
MIT -- see [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
pysearch/__init__.py,sha256=itjA-esHRCj8DdeWHzJWRv6kob7yqJiVnZEbEHqbuC0,84
|
|
2
|
+
pysearch/_version.py,sha256=RCY04NY0kBCyn6ttH6lnVL6Gz2gceOrt45f0yDOOU8A,520
|
|
3
|
+
pysearch/atomic.py,sha256=QLLPGZqpA-TgVV5lDEKCLB7vVr05bxj97_Fwykz_gi4,2345
|
|
4
|
+
pysearch/engine.py,sha256=FiGX5g8HoSag2WnEYEABV2KfOuS5b4qnalBuparjRco,5914
|
|
5
|
+
pysearch/errors.py,sha256=H5xkapTBr1ww7UqMKhUUfIJe5R0kp5xT_DBESkCNf4Y,607
|
|
6
|
+
pysearch/index.py,sha256=cdHZP4dhaRNJ7E7dI-3xNnqxzHtAWYJVNxRvgQr6w5Q,4107
|
|
7
|
+
pysearch/persistence.py,sha256=yCs5K-ZTV7MJ74Qhz6AQak51yOZEYS2XS1F7KZEokxw,2265
|
|
8
|
+
pysearch/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
pysearch/query.py,sha256=zkPpgLt9ikC1tsBddv_y1BWsaagp9-4k710Cb1ldfMs,3658
|
|
10
|
+
pysearch/tfidf.py,sha256=y4K5sG5GO-YQG0qIM6uqnvoJ0_OUuBMM7tAFBOVAUi4,3114
|
|
11
|
+
pysearch/tokenizer.py,sha256=Iq-W7g0sLHZaKAGwpqdBhzeI5oBWUOfj5aG_UYZjRCg,2686
|
|
12
|
+
fromscratch_pysearch-0.3.0.dist-info/METADATA,sha256=4BssHRVeJWfC7XEyhmtoM301Vu34zxa6ejwoUOiqu1Q,7998
|
|
13
|
+
fromscratch_pysearch-0.3.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
14
|
+
fromscratch_pysearch-0.3.0.dist-info/licenses/LICENSE,sha256=NtHM2ak--oE71VCcY50kcCoC1cKS0LRUsxq_xA89RNc,1064
|
|
15
|
+
fromscratch_pysearch-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rob Cox
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
pysearch/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""PySearch -- educational full-text search engine built from scratch in Python."""
|
pysearch/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.3.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 3, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
pysearch/atomic.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Safe saving -- never leave a half-written file behind.
|
|
2
|
+
|
|
3
|
+
The usual way to save a file is: open it, wipe it, write the new
|
|
4
|
+
contents. If the computer crashes after the wipe but before the writing
|
|
5
|
+
finishes, your data is gone -- the old version was wiped and the new one
|
|
6
|
+
never arrived.
|
|
7
|
+
|
|
8
|
+
So we save like a careful student copying out their homework:
|
|
9
|
+
|
|
10
|
+
1. Write the new version on a **fresh sheet** (a temporary file sitting
|
|
11
|
+
right next to the real one).
|
|
12
|
+
2. Make sure the **ink is dry** -- ``fsync`` forces the bytes out of the
|
|
13
|
+
computer's memory and onto the disk.
|
|
14
|
+
3. **Swap** the fresh sheet in for the old one in a single instant step
|
|
15
|
+
(``Path.replace``). There is never a moment with no complete file: you
|
|
16
|
+
see either the old version or the new one, never half of each.
|
|
17
|
+
|
|
18
|
+
Real databases (SQLite, PostgreSQL, Redis) all use this same trick.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import contextlib
|
|
22
|
+
import os
|
|
23
|
+
import stat
|
|
24
|
+
import tempfile
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
# Permissions for a brand-new file: owner can read/write, others can read.
|
|
28
|
+
_NEW_FILE_MODE = 0o644
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def atomic_write_text(path: Path, text: str) -> None:
|
|
32
|
+
"""Replace the file at *path* with *text*, so a crash can never leave it half-written.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
path: The file to create or replace (its folder must already exist).
|
|
36
|
+
text: The new contents, saved as UTF-8.
|
|
37
|
+
|
|
38
|
+
"""
|
|
39
|
+
mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else _NEW_FILE_MODE
|
|
40
|
+
fd, temp_name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
|
|
41
|
+
temp_path = Path(temp_name)
|
|
42
|
+
try:
|
|
43
|
+
with os.fdopen(fd, "w", encoding="utf-8") as temp_file:
|
|
44
|
+
temp_file.write(text)
|
|
45
|
+
temp_file.flush()
|
|
46
|
+
os.fsync(temp_file.fileno())
|
|
47
|
+
temp_path.chmod(mode)
|
|
48
|
+
temp_path.replace(path)
|
|
49
|
+
except BaseException:
|
|
50
|
+
temp_path.unlink(missing_ok=True) # don't leave the scrap sheet lying around
|
|
51
|
+
raise
|
|
52
|
+
_sync_folder(path.parent)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _sync_folder(folder: Path) -> None:
|
|
56
|
+
"""Make the swap itself durable by syncing the folder's list of files.
|
|
57
|
+
|
|
58
|
+
Some systems (like Windows) can't sync a folder; there the swap is
|
|
59
|
+
still safe, just not guaranteed to survive a power cut.
|
|
60
|
+
"""
|
|
61
|
+
with contextlib.suppress(OSError):
|
|
62
|
+
fd = os.open(folder, os.O_RDONLY)
|
|
63
|
+
try:
|
|
64
|
+
os.fsync(fd)
|
|
65
|
+
finally:
|
|
66
|
+
os.close(fd)
|
pysearch/engine.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Tie every component together into a complete search engine.
|
|
2
|
+
|
|
3
|
+
The search engine is the librarian herself. She knows how to file new books
|
|
4
|
+
(add documents), understand your question (parse queries), look things up
|
|
5
|
+
(search the index), and tell you which results matter most (rank by TF-IDF).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Self, cast
|
|
9
|
+
|
|
10
|
+
from pysearch.index import InvertedIndex
|
|
11
|
+
from pysearch.query import AndQuery, SearchQuery, TermQuery, parse_query
|
|
12
|
+
from pysearch.tfidf import rank_results
|
|
13
|
+
|
|
14
|
+
# Bump this if the saved layout ever changes, so old files are refused, not misread.
|
|
15
|
+
SAVE_FORMAT = 1
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SearchEngine:
|
|
19
|
+
"""A full-text search engine with TF-IDF ranking and boolean queries."""
|
|
20
|
+
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
"""Initialise an empty search engine."""
|
|
23
|
+
self._index = InvertedIndex()
|
|
24
|
+
# The original text of every book, in the order it arrived, so the
|
|
25
|
+
# engine can be saved and rebuilt exactly.
|
|
26
|
+
self._documents: dict[str, str] = {}
|
|
27
|
+
|
|
28
|
+
def add(self, doc_id: str, text: str) -> None:
|
|
29
|
+
"""Hand the librarian a new book to file away.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
doc_id: A unique identifier for the document.
|
|
33
|
+
text: The raw document text.
|
|
34
|
+
|
|
35
|
+
"""
|
|
36
|
+
self._index.add_document(doc_id, text)
|
|
37
|
+
self._documents[doc_id] = text
|
|
38
|
+
|
|
39
|
+
def to_dict(self) -> dict[str, object]:
|
|
40
|
+
"""Return everything needed to rebuild this engine, as plain data.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
``{"format": 1, "documents": {doc_id: text, ...}}``
|
|
44
|
+
|
|
45
|
+
"""
|
|
46
|
+
return {"format": SAVE_FORMAT, "documents": dict(self._documents)}
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def from_dict(cls, data: dict[str, object]) -> Self:
|
|
50
|
+
"""Rebuild an engine from :meth:`to_dict` data by re-filing every book.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
data: The saved data.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
A new engine holding the same documents.
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
ValueError: If *data* is from an unknown format version.
|
|
60
|
+
TypeError: If *data* doesn't have the expected shape.
|
|
61
|
+
|
|
62
|
+
"""
|
|
63
|
+
version = data.get("format")
|
|
64
|
+
# ``True == 1`` and ``1.0 == 1`` in Python, so insist on a real int.
|
|
65
|
+
if type(version) is not int or version != SAVE_FORMAT:
|
|
66
|
+
msg = f"unknown save format {version!r} (expected the integer {SAVE_FORMAT})"
|
|
67
|
+
raise ValueError(msg)
|
|
68
|
+
documents = data["documents"]
|
|
69
|
+
if not isinstance(documents, dict):
|
|
70
|
+
msg = "'documents' must be an object mapping ids to text"
|
|
71
|
+
raise TypeError(msg)
|
|
72
|
+
engine = cls()
|
|
73
|
+
for doc_id, text in cast("dict[object, object]", documents).items():
|
|
74
|
+
if not isinstance(doc_id, str) or not isinstance(text, str):
|
|
75
|
+
msg = "every document id and text must be a string"
|
|
76
|
+
raise TypeError(msg)
|
|
77
|
+
engine.add(doc_id, text)
|
|
78
|
+
return engine
|
|
79
|
+
|
|
80
|
+
def search(self, query_text: str) -> list[tuple[str, float]]:
|
|
81
|
+
"""Ask the librarian a question and get back the best-matching books, best first.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
query_text: A query string such as ``"cats"``, ``"cats AND dogs"``,
|
|
85
|
+
or ``"cats OR dogs"``.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
A list of ``(doc_id, score)`` tuples sorted by relevance.
|
|
89
|
+
|
|
90
|
+
"""
|
|
91
|
+
query = parse_query(query_text)
|
|
92
|
+
doc_ids = self._execute(query)
|
|
93
|
+
|
|
94
|
+
if not doc_ids:
|
|
95
|
+
return []
|
|
96
|
+
|
|
97
|
+
# Rank by ALL terms in the query so every word counts.
|
|
98
|
+
all_terms = self._collect_terms(query)
|
|
99
|
+
return rank_results(all_terms, doc_ids, self._index)
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def document_count(self) -> int:
|
|
103
|
+
"""Return the total number of indexed documents."""
|
|
104
|
+
return self._index.document_count
|
|
105
|
+
|
|
106
|
+
def _execute(self, query: SearchQuery) -> list[str]:
|
|
107
|
+
"""Work out which documents match a parsed query tree.
|
|
108
|
+
|
|
109
|
+
A long question makes a tall tree (2,000 words, 2,000 levels), too
|
|
110
|
+
tall for a function that calls itself once per level -- Python stops
|
|
111
|
+
at about 1,000 calls deep. So instead of calling itself, this keeps
|
|
112
|
+
its own to-do pile: each branch is visited once on the way down
|
|
113
|
+
(to queue its two halves) and once on the way back up (to join
|
|
114
|
+
their answers).
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
query: A parsed ``SearchQuery`` tree.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
A list of matching document IDs.
|
|
121
|
+
|
|
122
|
+
"""
|
|
123
|
+
to_do: list[tuple[SearchQuery, bool]] = [(query, False)]
|
|
124
|
+
answers: list[set[str]] = []
|
|
125
|
+
while to_do:
|
|
126
|
+
node, halves_done = to_do.pop()
|
|
127
|
+
if isinstance(node, TermQuery):
|
|
128
|
+
answers.append(set(self._index.search(node.term)))
|
|
129
|
+
elif not halves_done:
|
|
130
|
+
# Come back to this branch once both halves are answered.
|
|
131
|
+
to_do.extend([(node, True), (node.right, False), (node.left, False)])
|
|
132
|
+
else:
|
|
133
|
+
right = answers.pop()
|
|
134
|
+
left = answers.pop()
|
|
135
|
+
answers.append(left & right if isinstance(node, AndQuery) else left | right)
|
|
136
|
+
return list(answers[0])
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def _collect_terms(query: SearchQuery) -> list[str]:
|
|
140
|
+
"""Collect every term from a query tree for ranking, left to right.
|
|
141
|
+
|
|
142
|
+
Walk the whole tree and gather every leaf -- like picking every
|
|
143
|
+
apple off a tree instead of just the first one you see. Like
|
|
144
|
+
:meth:`_execute`, it keeps its own to-do pile so a tall tree can't
|
|
145
|
+
make it crash.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
query: A parsed ``SearchQuery`` tree.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
A list of all term strings in the query.
|
|
152
|
+
|
|
153
|
+
"""
|
|
154
|
+
terms: list[str] = []
|
|
155
|
+
to_do: list[SearchQuery] = [query]
|
|
156
|
+
while to_do:
|
|
157
|
+
node = to_do.pop()
|
|
158
|
+
if isinstance(node, TermQuery):
|
|
159
|
+
terms.append(node.term)
|
|
160
|
+
else:
|
|
161
|
+
to_do.extend([node.right, node.left]) # left comes off the pile first
|
|
162
|
+
return terms
|
pysearch/errors.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Search engine error types."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class PySearchError(Exception):
|
|
5
|
+
"""Base exception for all PySearch errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class QueryParseError(PySearchError):
|
|
9
|
+
"""Raise when a search query cannot be parsed."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DuplicateDocumentError(PySearchError):
|
|
13
|
+
"""Raise when a document id is added to the index a second time."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PersistenceError(PySearchError):
|
|
17
|
+
"""Raise when a saved search engine can't be read back.
|
|
18
|
+
|
|
19
|
+
Like opening the librarian's notebook and finding the pages torn or
|
|
20
|
+
scribbled on -- the file is missing, isn't valid JSON, or isn't a saved
|
|
21
|
+
engine at all.
|
|
22
|
+
"""
|
pysearch/index.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Build and query an inverted index.
|
|
2
|
+
|
|
3
|
+
An inverted index is like the librarian's memory. Instead of reading every
|
|
4
|
+
book to find a word, the librarian keeps a notebook: for each word, she writes
|
|
5
|
+
down which books contain it and how many times it appears. When you ask her
|
|
6
|
+
about a word, she just flips to that page in her notebook -- instant answer!
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections import defaultdict
|
|
10
|
+
|
|
11
|
+
from pysearch.errors import DuplicateDocumentError
|
|
12
|
+
from pysearch.tokenizer import simple_stem, tokenize
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class InvertedIndex:
|
|
16
|
+
"""Map terms to the documents that contain them.
|
|
17
|
+
|
|
18
|
+
The index stores two things for every (term, document) pair:
|
|
19
|
+
- which documents contain the term (for searching)
|
|
20
|
+
- how many times the term appears in each document (for ranking)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self) -> None:
|
|
24
|
+
"""Initialise an empty inverted index."""
|
|
25
|
+
self._index: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
|
26
|
+
self._doc_ids: set[str] = set()
|
|
27
|
+
|
|
28
|
+
def add_document(self, doc_id: str, text: str) -> None:
|
|
29
|
+
"""File a new book away -- read its words and note which ones live in this document.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
doc_id: A unique identifier for the document.
|
|
33
|
+
text: The raw document text.
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
DuplicateDocumentError: If a document with this id was already
|
|
37
|
+
added -- filing the same book twice would make every word in
|
|
38
|
+
it look more common than it really is.
|
|
39
|
+
|
|
40
|
+
"""
|
|
41
|
+
if doc_id in self._doc_ids:
|
|
42
|
+
msg = f"Document {doc_id!r} is already in the index"
|
|
43
|
+
raise DuplicateDocumentError(msg)
|
|
44
|
+
self._doc_ids.add(doc_id)
|
|
45
|
+
tokens = tokenize(text)
|
|
46
|
+
for token in tokens:
|
|
47
|
+
self._index[token][doc_id] += 1
|
|
48
|
+
|
|
49
|
+
def search(self, term: str) -> list[str]:
|
|
50
|
+
"""Ask the librarian which books contain a word -- she flips to that page and reads the list.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
term: A single search term (will be stemmed).
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
A list of matching document IDs.
|
|
57
|
+
|
|
58
|
+
"""
|
|
59
|
+
stemmed = simple_stem(term.lower())
|
|
60
|
+
return list(self._index.get(stemmed, {}))
|
|
61
|
+
|
|
62
|
+
def search_and(self, terms: list[str]) -> list[str]:
|
|
63
|
+
"""Find books that mention EVERY word you asked for -- both "cats" and "dogs", not just one.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
terms: A list of search terms.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Document IDs present in every term's posting list (intersection).
|
|
70
|
+
|
|
71
|
+
"""
|
|
72
|
+
if not terms:
|
|
73
|
+
return []
|
|
74
|
+
sets = [set(self.search(t)) for t in terms]
|
|
75
|
+
result = sets[0]
|
|
76
|
+
for s in sets[1:]:
|
|
77
|
+
result &= s
|
|
78
|
+
return list(result)
|
|
79
|
+
|
|
80
|
+
def search_or(self, terms: list[str]) -> list[str]:
|
|
81
|
+
"""Find books that mention ANY of your words -- "cats" or "dogs" or both will do.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
terms: A list of search terms.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Document IDs present in at least one term's posting list (union).
|
|
88
|
+
|
|
89
|
+
"""
|
|
90
|
+
if not terms:
|
|
91
|
+
return []
|
|
92
|
+
result: set[str] = set()
|
|
93
|
+
for t in terms:
|
|
94
|
+
result |= set(self.search(t))
|
|
95
|
+
return list(result)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def document_count(self) -> int:
|
|
99
|
+
"""Return the total number of indexed documents."""
|
|
100
|
+
return len(self._doc_ids)
|
|
101
|
+
|
|
102
|
+
def get_term_frequency(self, term: str, doc_id: str) -> int:
|
|
103
|
+
"""Return how many times *term* appears in *doc_id*.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
term: The search term (will be stemmed).
|
|
107
|
+
doc_id: The document identifier.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
The number of occurrences, or 0 if not found.
|
|
111
|
+
|
|
112
|
+
"""
|
|
113
|
+
stemmed = simple_stem(term.lower())
|
|
114
|
+
return self._index.get(stemmed, {}).get(doc_id, 0)
|
|
115
|
+
|
|
116
|
+
def get_document_frequency(self, term: str) -> int:
|
|
117
|
+
"""Return how many documents contain *term*.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
term: The search term (will be stemmed).
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
The number of documents containing the term.
|
|
124
|
+
|
|
125
|
+
"""
|
|
126
|
+
stemmed = simple_stem(term.lower())
|
|
127
|
+
return len(self._index.get(stemmed, {}))
|
pysearch/persistence.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Save a search engine to disk and load it back.
|
|
2
|
+
|
|
3
|
+
Closing the library for the night? The librarian writes every book's text
|
|
4
|
+
into a notebook (a JSON file). Tomorrow she reads the notebook and files
|
|
5
|
+
every book again, so every search gives the same answers as before.
|
|
6
|
+
|
|
7
|
+
Saving uses :func:`pysearch.atomic.atomic_write_text`, so a crash halfway
|
|
8
|
+
through can never leave a half-written notebook.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import cast
|
|
14
|
+
|
|
15
|
+
from pysearch.atomic import atomic_write_text
|
|
16
|
+
from pysearch.engine import SearchEngine
|
|
17
|
+
from pysearch.errors import PersistenceError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def save_engine(engine: SearchEngine, path: str | Path) -> None:
|
|
21
|
+
"""Save *engine* to a JSON file at *path*.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
engine: The engine to save.
|
|
25
|
+
path: Where to write it (its folder must already exist).
|
|
26
|
+
|
|
27
|
+
"""
|
|
28
|
+
atomic_write_text(Path(path), json.dumps(engine.to_dict(), indent=2))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_engine(path: str | Path) -> SearchEngine:
|
|
32
|
+
"""Load an engine saved by :func:`save_engine`.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
path: The file to read.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
A new engine holding the saved documents.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
PersistenceError: If the file is missing, unreadable, not text, not
|
|
42
|
+
valid JSON, or not a saved engine.
|
|
43
|
+
|
|
44
|
+
"""
|
|
45
|
+
file_path = Path(path)
|
|
46
|
+
try:
|
|
47
|
+
raw = file_path.read_text(encoding="utf-8")
|
|
48
|
+
except (OSError, UnicodeDecodeError) as exc: # missing, unreadable, or not text at all
|
|
49
|
+
msg = f"Failed to load search engine from {file_path}: {exc}"
|
|
50
|
+
raise PersistenceError(msg) from exc
|
|
51
|
+
# Not JSON (JSONDecodeError is a kind of ValueError), a number too long
|
|
52
|
+
# for Python to read (ValueError), or lists nested too deep (RecursionError).
|
|
53
|
+
try:
|
|
54
|
+
data = json.loads(raw)
|
|
55
|
+
except (ValueError, RecursionError) as exc:
|
|
56
|
+
msg = f"Invalid JSON in {file_path}: {exc}"
|
|
57
|
+
raise PersistenceError(msg) from exc
|
|
58
|
+
if not isinstance(data, dict):
|
|
59
|
+
msg = f"{file_path} is not a saved search engine"
|
|
60
|
+
raise PersistenceError(msg)
|
|
61
|
+
try:
|
|
62
|
+
return SearchEngine.from_dict(cast("dict[str, object]", data))
|
|
63
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
64
|
+
msg = f"{file_path} is not a saved search engine: {exc}"
|
|
65
|
+
raise PersistenceError(msg) from exc
|
pysearch/py.typed
ADDED
|
File without changes
|
pysearch/query.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Parse search queries into a structured representation.
|
|
2
|
+
|
|
3
|
+
Understanding your question is the first step to answering it. The query
|
|
4
|
+
parser reads what you typed -- like ``cats AND dogs`` -- and turns it into a
|
|
5
|
+
tree that the search engine can execute step by step.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from pysearch.errors import QueryParseError
|
|
11
|
+
from pysearch.tokenizer import split_words
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class TermQuery:
|
|
16
|
+
"""A query for a single search term."""
|
|
17
|
+
|
|
18
|
+
term: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class AndQuery:
|
|
23
|
+
"""A query requiring both the left and right sub-queries to match."""
|
|
24
|
+
|
|
25
|
+
left: TermQuery | AndQuery | OrQuery
|
|
26
|
+
right: TermQuery | AndQuery | OrQuery
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class OrQuery:
|
|
31
|
+
"""A query requiring either the left or right sub-query to match."""
|
|
32
|
+
|
|
33
|
+
left: TermQuery | AndQuery | OrQuery
|
|
34
|
+
right: TermQuery | AndQuery | OrQuery
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
SearchQuery = TermQuery | AndQuery | OrQuery
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_OPERATORS = frozenset({"AND", "OR"})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_query(text: str) -> SearchQuery:
|
|
44
|
+
"""Read what you typed -- like "cats AND dogs" -- and turn it into a plan the engine can follow.
|
|
45
|
+
|
|
46
|
+
Supported formats:
|
|
47
|
+
|
|
48
|
+
- ``"word"`` -- a single term
|
|
49
|
+
- ``"a AND b"`` / ``"a OR b"`` -- both / either must match
|
|
50
|
+
- ``"a b"`` -- words next to each other must *all* match (an implied AND)
|
|
51
|
+
- longer chains like ``"a AND b OR c"`` are read left to right:
|
|
52
|
+
``(a AND b) OR c``
|
|
53
|
+
|
|
54
|
+
Each word is cleaned up exactly like document text (see
|
|
55
|
+
:func:`~pysearch.tokenizer.split_words`): punctuation and stop words go,
|
|
56
|
+
and a word that splits into pieces (``"state-of-the-art"``) needs all of
|
|
57
|
+
its pieces.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
text: The raw query string.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
A ``SearchQuery`` representing the parsed query.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
QueryParseError: If the query is empty, has nothing searchable, or
|
|
67
|
+
has an operator without a word on both sides.
|
|
68
|
+
|
|
69
|
+
"""
|
|
70
|
+
words = text.split()
|
|
71
|
+
if not words:
|
|
72
|
+
msg = "Query must not be empty."
|
|
73
|
+
raise QueryParseError(msg)
|
|
74
|
+
|
|
75
|
+
result: SearchQuery | None = None
|
|
76
|
+
pending_operator = "AND"
|
|
77
|
+
expecting_term = True
|
|
78
|
+
for word in words:
|
|
79
|
+
operator = word.upper()
|
|
80
|
+
if operator in _OPERATORS:
|
|
81
|
+
if expecting_term:
|
|
82
|
+
msg = f"The {operator} operator needs a word on both sides: {text!r}"
|
|
83
|
+
raise QueryParseError(msg)
|
|
84
|
+
pending_operator = operator
|
|
85
|
+
expecting_term = True
|
|
86
|
+
continue
|
|
87
|
+
term = _word_query(word)
|
|
88
|
+
if term is None:
|
|
89
|
+
continue # a stop word or bare punctuation -- nothing to search for
|
|
90
|
+
result = term if result is None else _combine(result, pending_operator, term)
|
|
91
|
+
pending_operator = "AND"
|
|
92
|
+
expecting_term = False
|
|
93
|
+
|
|
94
|
+
if expecting_term and result is not None:
|
|
95
|
+
msg = f"The {pending_operator} operator needs a word on both sides: {text!r}"
|
|
96
|
+
raise QueryParseError(msg)
|
|
97
|
+
if result is None:
|
|
98
|
+
msg = f"Query has no searchable words (only stop words?): {text!r}"
|
|
99
|
+
raise QueryParseError(msg)
|
|
100
|
+
return result
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _word_query(word: str) -> SearchQuery | None:
|
|
104
|
+
"""Turn one typed word into a query (several pieces must all match)."""
|
|
105
|
+
pieces = [TermQuery(term=piece) for piece in split_words(word)]
|
|
106
|
+
if not pieces:
|
|
107
|
+
return None
|
|
108
|
+
query: SearchQuery = pieces[0]
|
|
109
|
+
for piece in pieces[1:]:
|
|
110
|
+
query = AndQuery(left=query, right=piece)
|
|
111
|
+
return query
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _combine(left: SearchQuery, operator: str, right: SearchQuery) -> SearchQuery:
|
|
115
|
+
"""Join two queries with AND or OR."""
|
|
116
|
+
if operator == "OR":
|
|
117
|
+
return OrQuery(left=left, right=right)
|
|
118
|
+
return AndQuery(left=left, right=right)
|
pysearch/tfidf.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Compute TF-IDF relevance scores.
|
|
2
|
+
|
|
3
|
+
TF-IDF stands for Term Frequency - Inverse Document Frequency. It answers the
|
|
4
|
+
question: "How important is this word to this particular document?" A word
|
|
5
|
+
that appears many times in one document but rarely in others gets a high
|
|
6
|
+
score -- it is probably what that document is *about*.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
|
|
11
|
+
from pysearch.index import InvertedIndex
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def compute_tf(term: str, doc_id: str, index: InvertedIndex) -> float:
|
|
15
|
+
"""Tally how many times a word shows up in one document -- more mentions, more relevant.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
term: The search term.
|
|
19
|
+
doc_id: The document identifier.
|
|
20
|
+
index: The inverted index to query.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
The raw count of *term* in *doc_id* as a float.
|
|
24
|
+
|
|
25
|
+
"""
|
|
26
|
+
return float(index.get_term_frequency(term, doc_id))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def compute_idf(term: str, index: InvertedIndex) -> float:
|
|
30
|
+
"""Figure out how special a word is -- common words like "the" score near zero, rare words like "dinosaur" score high.
|
|
31
|
+
|
|
32
|
+
Uses the formula ``log(N / df)`` where *N* is the total number of
|
|
33
|
+
documents and *df* is the number of documents containing *term*. Note that a
|
|
34
|
+
word appearing in *every* document (or the only document) scores exactly
|
|
35
|
+
``0.0`` -- a fun exercise is to smooth this to ``log(N / df) + 1`` so even a
|
|
36
|
+
lone match keeps a small positive score.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
term: The search term.
|
|
40
|
+
index: The inverted index to query.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
The IDF value, or 0.0 if the term appears in no documents.
|
|
44
|
+
|
|
45
|
+
"""
|
|
46
|
+
df = index.get_document_frequency(term)
|
|
47
|
+
if df == 0:
|
|
48
|
+
return 0.0
|
|
49
|
+
return math.log(index.document_count / df)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def compute_tfidf(term: str, doc_id: str, index: InvertedIndex) -> float:
|
|
53
|
+
"""Blend "how often" with "how special" into one relevance score for a word in a document.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
term: The search term.
|
|
57
|
+
doc_id: The document identifier.
|
|
58
|
+
index: The inverted index to query.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
The product of TF and IDF.
|
|
62
|
+
|
|
63
|
+
"""
|
|
64
|
+
return compute_tf(term, doc_id, index) * compute_idf(term, index)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def rank_results(
|
|
68
|
+
terms: str | list[str],
|
|
69
|
+
doc_ids: list[str],
|
|
70
|
+
index: InvertedIndex,
|
|
71
|
+
) -> list[tuple[str, float]]:
|
|
72
|
+
"""Rank documents by their TF-IDF score across all *terms*, highest first.
|
|
73
|
+
|
|
74
|
+
Think of it like a school report card: each subject (term) gives a grade
|
|
75
|
+
(TF-IDF score), and we add them all up to get the final grade. A document
|
|
76
|
+
that scores well on *every* search word floats to the top.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
terms: A single search term or a list of terms.
|
|
80
|
+
doc_ids: The candidate document IDs to rank.
|
|
81
|
+
index: The inverted index to query.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
A list of ``(doc_id, score)`` tuples sorted by score descending.
|
|
85
|
+
|
|
86
|
+
"""
|
|
87
|
+
term_list = [terms] if isinstance(terms, str) else terms
|
|
88
|
+
scored = [
|
|
89
|
+
(doc_id, sum(compute_tfidf(t, doc_id, index) for t in term_list)) for doc_id in doc_ids
|
|
90
|
+
]
|
|
91
|
+
# Best score first; equal scores are listed by document id so the
|
|
92
|
+
# order never depends on luck.
|
|
93
|
+
scored.sort(key=lambda pair: (-pair[1], pair[0]))
|
|
94
|
+
return scored
|
pysearch/tokenizer.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Tokenize text into searchable words.
|
|
2
|
+
|
|
3
|
+
Think of a tokenizer like cutting sentences into word cards. You take a long
|
|
4
|
+
sentence, snip it into individual words, throw away boring words like "the"
|
|
5
|
+
and "is," and keep the interesting ones. Each card can then be filed in the
|
|
6
|
+
index so you can find it later.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
STOP_WORDS: frozenset[str] = frozenset(
|
|
12
|
+
{
|
|
13
|
+
"the",
|
|
14
|
+
"a",
|
|
15
|
+
"an",
|
|
16
|
+
"is",
|
|
17
|
+
"are",
|
|
18
|
+
"was",
|
|
19
|
+
"were",
|
|
20
|
+
"in",
|
|
21
|
+
"on",
|
|
22
|
+
"at",
|
|
23
|
+
"to",
|
|
24
|
+
"for",
|
|
25
|
+
"of",
|
|
26
|
+
"and",
|
|
27
|
+
"or",
|
|
28
|
+
"but",
|
|
29
|
+
"not",
|
|
30
|
+
"it",
|
|
31
|
+
"this",
|
|
32
|
+
"that",
|
|
33
|
+
"with",
|
|
34
|
+
"from",
|
|
35
|
+
"by",
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
_SUFFIX_ORDER: tuple[str, ...] = ("ing", "ed", "ly", "er", "est", "es", "s")
|
|
40
|
+
_MIN_STEM_LENGTH = 3
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def split_words(text: str) -> list[str]:
|
|
44
|
+
"""Cut text into clean words -- lowercased, punctuation gone, boring words tossed.
|
|
45
|
+
|
|
46
|
+
This is the first half of :func:`tokenize`, without the stemming. Search
|
|
47
|
+
queries use it too, so a query word is cleaned up *exactly* the way the
|
|
48
|
+
documents were: ``"fox,"`` becomes ``"fox"``, and ``"state-of-the-art"``
|
|
49
|
+
becomes ``["state", "art"]``.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
text: The raw text to split.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
A list of lowercase words with stop words removed (not yet stemmed).
|
|
56
|
+
|
|
57
|
+
"""
|
|
58
|
+
words = re.split(r"[^a-zA-Z0-9]+", text.lower())
|
|
59
|
+
return [w for w in words if w and w not in STOP_WORDS]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def tokenize(text: str) -> list[str]:
|
|
63
|
+
"""Cut a sentence into clean word cards -- lowercased, punctuation gone, boring words tossed.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
text: The raw text to tokenize.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
A list of stemmed, lowercase tokens with stop words removed.
|
|
70
|
+
|
|
71
|
+
"""
|
|
72
|
+
return [simple_stem(w) for w in split_words(text)]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def simple_stem(word: str) -> str:
|
|
76
|
+
"""Trim a word down to its root so "foxes" and "fox" count as the same word.
|
|
77
|
+
|
|
78
|
+
We only chop an ending off if enough of the word is left over (at least 3
|
|
79
|
+
letters), so tiny words like "bed" stay whole. Plural "-ies" becomes "-y"
|
|
80
|
+
("berries" -> "berry"), plural "-es" is dropped ("foxes" -> "fox"), and the
|
|
81
|
+
other endings checked in order are: "ing", "ed", "ly", "er", "est", "s".
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
word: The word to stem.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
The stemmed word.
|
|
88
|
+
|
|
89
|
+
"""
|
|
90
|
+
# Plural "-ies" -> "-y" so "berries" and "berry" collapse to the same stem.
|
|
91
|
+
if word.endswith("ies") and len(word) - len("ies") >= _MIN_STEM_LENGTH - 1:
|
|
92
|
+
return word[:-3] + "y"
|
|
93
|
+
for suffix in _SUFFIX_ORDER:
|
|
94
|
+
if word.endswith(suffix) and len(word) - len(suffix) >= _MIN_STEM_LENGTH:
|
|
95
|
+
return word[: -len(suffix)]
|
|
96
|
+
return word
|