vruksha 0.0.2__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.
- vruksha-0.0.2/.gitignore +164 -0
- vruksha-0.0.2/LICENSE +201 -0
- vruksha-0.0.2/PKG-INFO +74 -0
- vruksha-0.0.2/README.md +52 -0
- vruksha-0.0.2/pyproject.toml +47 -0
- vruksha-0.0.2/vruksha/__init__.py +4 -0
- vruksha-0.0.2/vruksha/_modidx.py +47 -0
- vruksha-0.0.2/vruksha/build.py +438 -0
- vruksha-0.0.2/vruksha/entities.py +141 -0
- vruksha-0.0.2/vruksha/search.py +164 -0
vruksha-0.0.2/.gitignore
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
_docs/
|
|
2
|
+
_proc/
|
|
3
|
+
|
|
4
|
+
*.bak
|
|
5
|
+
.gitattributes
|
|
6
|
+
.last_checked
|
|
7
|
+
.gitconfig
|
|
8
|
+
*.bak
|
|
9
|
+
*.log
|
|
10
|
+
*~
|
|
11
|
+
~*
|
|
12
|
+
_tmp*
|
|
13
|
+
tmp*
|
|
14
|
+
tags
|
|
15
|
+
*.pkg
|
|
16
|
+
|
|
17
|
+
# Byte-compiled / optimized / DLL files
|
|
18
|
+
__pycache__/
|
|
19
|
+
*.py[cod]
|
|
20
|
+
*$py.class
|
|
21
|
+
|
|
22
|
+
# C extensions
|
|
23
|
+
*.so
|
|
24
|
+
|
|
25
|
+
# Distribution / packaging
|
|
26
|
+
.Python
|
|
27
|
+
env/
|
|
28
|
+
build/
|
|
29
|
+
conda/
|
|
30
|
+
develop-eggs/
|
|
31
|
+
dist/
|
|
32
|
+
downloads/
|
|
33
|
+
eggs/
|
|
34
|
+
.eggs/
|
|
35
|
+
lib/
|
|
36
|
+
lib64/
|
|
37
|
+
parts/
|
|
38
|
+
sdist/
|
|
39
|
+
var/
|
|
40
|
+
wheels/
|
|
41
|
+
*.egg-info/
|
|
42
|
+
.installed.cfg
|
|
43
|
+
*.egg
|
|
44
|
+
|
|
45
|
+
# PyInstaller
|
|
46
|
+
# Usually these files are written by a python script from a template
|
|
47
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
48
|
+
*.manifest
|
|
49
|
+
*.spec
|
|
50
|
+
|
|
51
|
+
# Installer logs
|
|
52
|
+
pip-log.txt
|
|
53
|
+
pip-delete-this-directory.txt
|
|
54
|
+
|
|
55
|
+
# Unit test / coverage reports
|
|
56
|
+
htmlcov/
|
|
57
|
+
.tox/
|
|
58
|
+
.coverage
|
|
59
|
+
.coverage.*
|
|
60
|
+
.cache
|
|
61
|
+
nosetests.xml
|
|
62
|
+
coverage.xml
|
|
63
|
+
*.cover
|
|
64
|
+
.hypothesis/
|
|
65
|
+
|
|
66
|
+
# Translations
|
|
67
|
+
*.mo
|
|
68
|
+
*.pot
|
|
69
|
+
|
|
70
|
+
# Django stuff:
|
|
71
|
+
*.log
|
|
72
|
+
local_settings.py
|
|
73
|
+
|
|
74
|
+
# Flask stuff:
|
|
75
|
+
instance/
|
|
76
|
+
.webassets-cache
|
|
77
|
+
|
|
78
|
+
# Scrapy stuff:
|
|
79
|
+
.scrapy
|
|
80
|
+
|
|
81
|
+
# Sphinx documentation
|
|
82
|
+
docs/_build/
|
|
83
|
+
|
|
84
|
+
# PyBuilder
|
|
85
|
+
target/
|
|
86
|
+
|
|
87
|
+
# Jupyter Notebook
|
|
88
|
+
.ipynb_checkpoints
|
|
89
|
+
|
|
90
|
+
# pyenv
|
|
91
|
+
.python-version
|
|
92
|
+
|
|
93
|
+
# celery beat schedule file
|
|
94
|
+
celerybeat-schedule
|
|
95
|
+
|
|
96
|
+
# SageMath parsed files
|
|
97
|
+
*.sage.py
|
|
98
|
+
|
|
99
|
+
# dotenv
|
|
100
|
+
.env
|
|
101
|
+
|
|
102
|
+
# virtualenv
|
|
103
|
+
.venv
|
|
104
|
+
venv/
|
|
105
|
+
ENV/
|
|
106
|
+
|
|
107
|
+
# Spyder project settings
|
|
108
|
+
.spyderproject
|
|
109
|
+
.spyproject
|
|
110
|
+
|
|
111
|
+
# Rope project settings
|
|
112
|
+
.ropeproject
|
|
113
|
+
|
|
114
|
+
# mkdocs documentation
|
|
115
|
+
/site
|
|
116
|
+
|
|
117
|
+
# mypy
|
|
118
|
+
.mypy_cache/
|
|
119
|
+
|
|
120
|
+
.vscode
|
|
121
|
+
*.swp
|
|
122
|
+
|
|
123
|
+
# osx generated files
|
|
124
|
+
.DS_Store
|
|
125
|
+
.DS_Store?
|
|
126
|
+
.Trashes
|
|
127
|
+
ehthumbs.db
|
|
128
|
+
Thumbs.db
|
|
129
|
+
.idea
|
|
130
|
+
|
|
131
|
+
# pytest
|
|
132
|
+
.pytest_cache
|
|
133
|
+
|
|
134
|
+
# tools/trust-doc-nbs
|
|
135
|
+
docs_src/.last_checked
|
|
136
|
+
|
|
137
|
+
# symlinks to fastai
|
|
138
|
+
docs_src/fastai
|
|
139
|
+
tools/fastai
|
|
140
|
+
|
|
141
|
+
# link checker
|
|
142
|
+
checklink/cookies.txt
|
|
143
|
+
|
|
144
|
+
# .gitconfig is now autogenerated
|
|
145
|
+
.gitconfig
|
|
146
|
+
|
|
147
|
+
# Quarto installer
|
|
148
|
+
.deb
|
|
149
|
+
.pkg
|
|
150
|
+
|
|
151
|
+
# Quarto
|
|
152
|
+
.quarto
|
|
153
|
+
|
|
154
|
+
# local vault + extracted assets
|
|
155
|
+
*.db
|
|
156
|
+
*.usearch
|
|
157
|
+
# ...except the recorded model replies the notebook tests replay in CI. The sqlite side files are
|
|
158
|
+
# transient, so they stay ignored.
|
|
159
|
+
!nbs/chatcache/cache.db
|
|
160
|
+
nbs/chatcache/*.db-wal
|
|
161
|
+
nbs/chatcache/*.db-shm
|
|
162
|
+
assets/
|
|
163
|
+
.venv/
|
|
164
|
+
.kosha/
|
vruksha-0.0.2/LICENSE
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright 2022, fastai
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|
vruksha-0.0.2/PKG-INFO
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: vruksha
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: an entity graph over a litesearch store: extraction, resolution, and a PageRank search leg
|
|
5
|
+
Project-URL: Repository, https://github.com/vedicreader/vruksha
|
|
6
|
+
Project-URL: Documentation, https://vedicreader.github.io/vruksha/
|
|
7
|
+
Author-email: Karthik <karthik.rajgopal@hotmail.com>
|
|
8
|
+
License: Apache-2.0
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: entity resolution,knowledge graph,nbdev,pagerank,search,sqlite
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Topic :: Text Processing :: Indexing
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: fastcore>=2.2.15
|
|
19
|
+
Requires-Dist: litesearch>=0.1.33
|
|
20
|
+
Requires-Dist: yake>=0.7.3
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# vruksha
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
<!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! -->
|
|
27
|
+
|
|
28
|
+
Four problems, and none of them needs a model.
|
|
29
|
+
|
|
30
|
+
| problem | how it is solved |
|
|
31
|
+
|----|----|
|
|
32
|
+
| what the entities are | AST symbols for code, yake keyphrases for prose |
|
|
33
|
+
| which mentions are the same thing | embeddings propose, a lexical guard decides |
|
|
34
|
+
| what connects to what | PMI over co-occurrence windows |
|
|
35
|
+
| what to do with the graph | personalised PageRank, fused as a third search leg |
|
|
36
|
+
|
|
37
|
+
``` python
|
|
38
|
+
from litesearch import database
|
|
39
|
+
from vruksha import build_graph, resolve_entities
|
|
40
|
+
|
|
41
|
+
db = database('corpus.db')
|
|
42
|
+
build_graph(db, rows, emb_fn=enc) # entities and co-occurrence edges
|
|
43
|
+
resolve_entities(db) # `hnsw` and `HNSW index` become one node
|
|
44
|
+
db.graph_search('how does resolution work', qemb, graph_w=0.5)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## The lexical guard
|
|
48
|
+
|
|
49
|
+
Embedding similarity alone merges `python 3.11` into `python 3.12`. [`_lex_ok`](https://vedicreader.github.io/vruksha/entities.html#_lex_ok) requires token
|
|
50
|
+
overlap, matching digits and a matching acronym before a merge goes through.
|
|
51
|
+
|
|
52
|
+
``` python
|
|
53
|
+
from vruksha.entities import _lex_ok
|
|
54
|
+
|
|
55
|
+
_lex_ok('usearch', 'usearch index'), _lex_ok('python 3.11', 'python 3.12')
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## When to turn the search leg on
|
|
59
|
+
|
|
60
|
+
Measured against plain hybrid search:
|
|
61
|
+
|
|
62
|
+
- **Regulation and legal text: a loss.** p_mrr 0.8170 for plain hybrid against 0.7395, 0.6859 and
|
|
63
|
+
0.6463 at `graph_w` 0.25, 0.5 and 1.0, at two to four times the latency.
|
|
64
|
+
- **Papers and prose: a win.** Better in seven of nine paired-bootstrap comparisons, +0.0387
|
|
65
|
+
target MRR on arXiv at `graph_w=1.0`.
|
|
66
|
+
|
|
67
|
+
So `graph_search` is opt-in by name and off by default. Turn it on for a corpus whose entities
|
|
68
|
+
carry meaning, and raise `graph_w` towards 1.0 when you do.
|
|
69
|
+
|
|
70
|
+
## Install
|
|
71
|
+
|
|
72
|
+
``` sh
|
|
73
|
+
pip install vruksha
|
|
74
|
+
```
|
vruksha-0.0.2/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# vruksha
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
<!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! -->
|
|
5
|
+
|
|
6
|
+
Four problems, and none of them needs a model.
|
|
7
|
+
|
|
8
|
+
| problem | how it is solved |
|
|
9
|
+
|----|----|
|
|
10
|
+
| what the entities are | AST symbols for code, yake keyphrases for prose |
|
|
11
|
+
| which mentions are the same thing | embeddings propose, a lexical guard decides |
|
|
12
|
+
| what connects to what | PMI over co-occurrence windows |
|
|
13
|
+
| what to do with the graph | personalised PageRank, fused as a third search leg |
|
|
14
|
+
|
|
15
|
+
``` python
|
|
16
|
+
from litesearch import database
|
|
17
|
+
from vruksha import build_graph, resolve_entities
|
|
18
|
+
|
|
19
|
+
db = database('corpus.db')
|
|
20
|
+
build_graph(db, rows, emb_fn=enc) # entities and co-occurrence edges
|
|
21
|
+
resolve_entities(db) # `hnsw` and `HNSW index` become one node
|
|
22
|
+
db.graph_search('how does resolution work', qemb, graph_w=0.5)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## The lexical guard
|
|
26
|
+
|
|
27
|
+
Embedding similarity alone merges `python 3.11` into `python 3.12`. [`_lex_ok`](https://vedicreader.github.io/vruksha/entities.html#_lex_ok) requires token
|
|
28
|
+
overlap, matching digits and a matching acronym before a merge goes through.
|
|
29
|
+
|
|
30
|
+
``` python
|
|
31
|
+
from vruksha.entities import _lex_ok
|
|
32
|
+
|
|
33
|
+
_lex_ok('usearch', 'usearch index'), _lex_ok('python 3.11', 'python 3.12')
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## When to turn the search leg on
|
|
37
|
+
|
|
38
|
+
Measured against plain hybrid search:
|
|
39
|
+
|
|
40
|
+
- **Regulation and legal text: a loss.** p_mrr 0.8170 for plain hybrid against 0.7395, 0.6859 and
|
|
41
|
+
0.6463 at `graph_w` 0.25, 0.5 and 1.0, at two to four times the latency.
|
|
42
|
+
- **Papers and prose: a win.** Better in seven of nine paired-bootstrap comparisons, +0.0387
|
|
43
|
+
target MRR on arXiv at `graph_w=1.0`.
|
|
44
|
+
|
|
45
|
+
So `graph_search` is opt-in by name and off by default. Turn it on for a corpus whose entities
|
|
46
|
+
carry meaning, and raise `graph_w` towards 1.0 when you do.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
``` sh
|
|
51
|
+
pip install vruksha
|
|
52
|
+
```
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vruksha"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "an entity graph over a litesearch store: extraction, resolution, and a PageRank search leg"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = {text = "Apache-2.0"}
|
|
12
|
+
authors = [{name = "Karthik", email = "karthik.rajgopal@hotmail.com"}]
|
|
13
|
+
keywords = ['nbdev', 'knowledge graph', 'entity resolution', 'pagerank', 'search', 'sqlite']
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: Apache Software License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Topic :: Text Processing :: Indexing",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"fastcore>=2.2.15",
|
|
24
|
+
"litesearch>=0.1.33",
|
|
25
|
+
"yake>=0.7.3",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Repository = "https://github.com/vedicreader/vruksha"
|
|
30
|
+
Documentation = "https://vedicreader.github.io/vruksha/"
|
|
31
|
+
|
|
32
|
+
[project.entry-points.nbdev]
|
|
33
|
+
vruksha = "vruksha._modidx:d"
|
|
34
|
+
|
|
35
|
+
[tool.nbdev]
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["vruksha"]
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.sdist]
|
|
41
|
+
include = ["/vruksha", "/README.md", "/pyproject.toml"]
|
|
42
|
+
|
|
43
|
+
[tool.hatch.version]
|
|
44
|
+
path = "vruksha/__init__.py"
|
|
45
|
+
|
|
46
|
+
[dependency-groups]
|
|
47
|
+
dev = ["ipykernel>=7.3.0", "nbdev>=3.3.4", "notebook>=7.6.1"]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Autogenerated by nbdev
|
|
2
|
+
|
|
3
|
+
d = { 'settings': { 'branch': 'main',
|
|
4
|
+
'doc_baseurl': '/vruksha',
|
|
5
|
+
'doc_host': 'https://vedicreader.github.io',
|
|
6
|
+
'git_url': 'https://github.com/vedicreader/vruksha',
|
|
7
|
+
'lib_path': 'vruksha'},
|
|
8
|
+
'syms': { 'vruksha.build': { 'vruksha.build._WindowStore': ('build.html#_windowstore', 'vruksha/build.py'),
|
|
9
|
+
'vruksha.build._WindowStore.__init__': ('build.html#_windowstore.__init__', 'vruksha/build.py'),
|
|
10
|
+
'vruksha.build._WindowStore.__iter__': ('build.html#_windowstore.__iter__', 'vruksha/build.py'),
|
|
11
|
+
'vruksha.build._WindowStore.__len__': ('build.html#_windowstore.__len__', 'vruksha/build.py'),
|
|
12
|
+
'vruksha.build._WindowStore.drop': ('build.html#_windowstore.drop', 'vruksha/build.py'),
|
|
13
|
+
'vruksha.build._WindowStore.entity_counts': ('build.html#_windowstore.entity_counts', 'vruksha/build.py'),
|
|
14
|
+
'vruksha.build._WindowStore.extend': ('build.html#_windowstore.extend', 'vruksha/build.py'),
|
|
15
|
+
'vruksha.build._WindowStore.pair_counts': ('build.html#_windowstore.pair_counts', 'vruksha/build.py'),
|
|
16
|
+
'vruksha.build._ann_pairs': ('build.html#_ann_pairs', 'vruksha/build.py'),
|
|
17
|
+
'vruksha.build._collapse_edges': ('build.html#_collapse_edges', 'vruksha/build.py'),
|
|
18
|
+
'vruksha.build._is_code': ('build.html#_is_code', 'vruksha/build.py'),
|
|
19
|
+
'vruksha.build._lexical_pairs': ('build.html#_lexical_pairs', 'vruksha/build.py'),
|
|
20
|
+
'vruksha.build._n_workers': ('build.html#_n_workers', 'vruksha/build.py'),
|
|
21
|
+
'vruksha.build._pmi_edges': ('build.html#_pmi_edges', 'vruksha/build.py'),
|
|
22
|
+
'vruksha.build._pool': ('build.html#_pool', 'vruksha/build.py'),
|
|
23
|
+
'vruksha.build._prose_job': ('build.html#_prose_job', 'vruksha/build.py'),
|
|
24
|
+
'vruksha.build._uf_find': ('build.html#_uf_find', 'vruksha/build.py'),
|
|
25
|
+
'vruksha.build._uf_union': ('build.html#_uf_union', 'vruksha/build.py'),
|
|
26
|
+
'vruksha.build.build_graph': ('build.html#build_graph', 'vruksha/build.py'),
|
|
27
|
+
'vruksha.build.cooccur_edges': ('build.html#cooccur_edges', 'vruksha/build.py'),
|
|
28
|
+
'vruksha.build.resolve_entities': ('build.html#resolve_entities', 'vruksha/build.py')},
|
|
29
|
+
'vruksha.entities': { 'vruksha.entities._acr': ('entities.html#_acr', 'vruksha/entities.py'),
|
|
30
|
+
'vruksha.entities._def_name': ('entities.html#_def_name', 'vruksha/entities.py'),
|
|
31
|
+
'vruksha.entities._jac': ('entities.html#_jac', 'vruksha/entities.py'),
|
|
32
|
+
'vruksha.entities._lex_ok': ('entities.html#_lex_ok', 'vruksha/entities.py'),
|
|
33
|
+
'vruksha.entities._norm': ('entities.html#_norm', 'vruksha/entities.py'),
|
|
34
|
+
'vruksha.entities._nums': ('entities.html#_nums', 'vruksha/entities.py'),
|
|
35
|
+
'vruksha.entities._sentences': ('entities.html#_sentences', 'vruksha/entities.py'),
|
|
36
|
+
'vruksha.entities._toks': ('entities.html#_toks', 'vruksha/entities.py'),
|
|
37
|
+
'vruksha.entities._yake_terms': ('entities.html#_yake_terms', 'vruksha/entities.py'),
|
|
38
|
+
'vruksha.entities.code_entities': ('entities.html#code_entities', 'vruksha/entities.py'),
|
|
39
|
+
'vruksha.entities.prose_windows': ('entities.html#prose_windows', 'vruksha/entities.py'),
|
|
40
|
+
'vruksha.entities.text_entities': ('entities.html#text_entities', 'vruksha/entities.py')},
|
|
41
|
+
'vruksha.search': { 'vruksha.search.Database.graph_search': ('search.html#database.graph_search', 'vruksha/search.py'),
|
|
42
|
+
'vruksha.search._adjacency': ('search.html#_adjacency', 'vruksha/search.py'),
|
|
43
|
+
'vruksha.search._canon_mentions': ('search.html#_canon_mentions', 'vruksha/search.py'),
|
|
44
|
+
'vruksha.search._csr': ('search.html#_csr', 'vruksha/search.py'),
|
|
45
|
+
'vruksha.search._leg': ('search.html#_leg', 'vruksha/search.py'),
|
|
46
|
+
'vruksha.search._ppr': ('search.html#_ppr', 'vruksha/search.py'),
|
|
47
|
+
'vruksha.search.graph_stats': ('search.html#graph_stats', 'vruksha/search.py')}}}
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/01_build.ipynb.
|
|
2
|
+
|
|
3
|
+
# %% auto #0
|
|
4
|
+
__all__ = ['MIN_PARALLEL_CHUNKS', 'build_graph', 'resolve_entities', 'cooccur_edges']
|
|
5
|
+
|
|
6
|
+
# %% ../nbs/01_build.ipynb #ab82e9d12af8898f
|
|
7
|
+
import ast, math, re, sys
|
|
8
|
+
from functools import lru_cache
|
|
9
|
+
import numpy as np
|
|
10
|
+
from fastcore.all import AttrDict, L, Path, chunked, defaults, first, ifnone, merge, patch, store_attr
|
|
11
|
+
from fastcore.parallel import ProcessPoolExecutor
|
|
12
|
+
from fastlite import Database
|
|
13
|
+
from apswutils.db import Table
|
|
14
|
+
from multiprocessing import get_context
|
|
15
|
+
from litesearch.core import (sql_in, rowid_sel, content_id, NP_DTYPE, process_content, write_txn, db_lock,
|
|
16
|
+
upsert_all, rrf_all)
|
|
17
|
+
from litesearch.topics import get_graph
|
|
18
|
+
from litesearch.utils import hash_embed
|
|
19
|
+
from .entities import (_acr, _jac, _lex_ok, _norm, _nums, _sentences, _toks, _yake_terms,
|
|
20
|
+
code_entities, prose_windows, text_entities)
|
|
21
|
+
|
|
22
|
+
# %% ../nbs/01_build.ipynb #8e1af70983a03a3f
|
|
23
|
+
_CODE_TYPES = {'FunctionDef','AsyncFunctionDef','ClassDef'}
|
|
24
|
+
|
|
25
|
+
def _is_code(chunk):
|
|
26
|
+
md = chunk.get('metadata') or {}
|
|
27
|
+
if not isinstance(md, dict): return False
|
|
28
|
+
return md.get('lang') == '.py' or md.get('type') in _CODE_TYPES
|
|
29
|
+
|
|
30
|
+
def _pmi_edges(wins, # list of entity-id sets (one per co-occurrence window)
|
|
31
|
+
min_n=2, # min co-occurrence count
|
|
32
|
+
min_npmi=0.15, # min normalized PMI
|
|
33
|
+
max_df=0.4, # drop entities present in more than this fraction of windows
|
|
34
|
+
max_degree=48, # keep only the strongest edges per node
|
|
35
|
+
rel='cooc'):
|
|
36
|
+
'Normalized-PMI co-occurrence edges over windows. max_df is what kills hub terms like "section".'
|
|
37
|
+
n = len(wins)
|
|
38
|
+
if n < 2: return []
|
|
39
|
+
if hasattr(wins, 'pair_counts'): # a _WindowStore counts on disk, same numbers
|
|
40
|
+
cnt = wins.entity_counts()
|
|
41
|
+
hub = {e for e, c in cnt.items() if c / n > max_df}
|
|
42
|
+
pairs = ((a, b, c) for a, b, c in wins.pair_counts(hub, min_n))
|
|
43
|
+
else:
|
|
44
|
+
cnt = {}
|
|
45
|
+
for w in wins:
|
|
46
|
+
for e in w: cnt[e] = cnt.get(e, 0) + 1
|
|
47
|
+
hub = {e for e, c in cnt.items() if c / n > max_df}
|
|
48
|
+
pair = {}
|
|
49
|
+
for w in wins:
|
|
50
|
+
es = sorted(e for e in w if e not in hub)
|
|
51
|
+
for i, a in enumerate(es):
|
|
52
|
+
for b in es[i+1:]: pair[(a, b)] = pair.get((a, b), 0) + 1
|
|
53
|
+
pairs = ((a, b, c) for (a, b), c in pair.items())
|
|
54
|
+
out = []
|
|
55
|
+
for a, b, c in pairs:
|
|
56
|
+
if c < min_n: continue
|
|
57
|
+
pa, pb, pab = cnt[a]/n, cnt[b]/n, c/n
|
|
58
|
+
npmi = math.log(pab/(pa*pb)) / (-math.log(pab)) if 0 < pab < 1 else 0.0
|
|
59
|
+
if npmi < min_npmi: continue
|
|
60
|
+
out.append(dict(src=a, dst=b, rel=rel, weight=round(npmi, 5), n=c))
|
|
61
|
+
if max_degree:
|
|
62
|
+
# (src, dst) breaks weight ties explicitly. Without it the survivors of the degree cap depend
|
|
63
|
+
# on the order pairs were counted in, which differs between the in-memory dict and SQLite's
|
|
64
|
+
# group-by — 30 of 2,707 edges on a 500-chunk corpus, all of them ties, none of them wrong.
|
|
65
|
+
deg, kept = {}, []
|
|
66
|
+
for e in sorted(out, key=lambda r: (-r['weight'], r['src'], r['dst'])):
|
|
67
|
+
if deg.get(e['src'], 0) < max_degree and deg.get(e['dst'], 0) < max_degree:
|
|
68
|
+
deg[e['src']] = deg.get(e['src'], 0)+1; deg[e['dst']] = deg.get(e['dst'], 0)+1
|
|
69
|
+
kept.append(e)
|
|
70
|
+
out = kept
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
# Below this many prose chunks in one drain, a process pool costs more to start than it saves.
|
|
74
|
+
MIN_PARALLEL_CHUNKS = 200
|
|
75
|
+
|
|
76
|
+
def _prose_job(txt):
|
|
77
|
+
'''Windows for one prose chunk, as plain tuples. Module level so a process pool can pickle it.
|
|
78
|
+
|
|
79
|
+
Only text crosses the boundary and only tuples come back — no database handle, nothing that a
|
|
80
|
+
fork would have to keep consistent.'''
|
|
81
|
+
return [[(s, k) for s, k in w] for w in prose_windows(txt)]
|
|
82
|
+
|
|
83
|
+
def _pool(nw):
|
|
84
|
+
'''A `ProcessPoolExecutor` started the way `fastcore.parallel` starts one.
|
|
85
|
+
|
|
86
|
+
fork, not spawn, on darwin. A spawned worker unpickles the job by module and name, and under
|
|
87
|
+
nbdev `_prose_job` is defined in the notebook's `__main__`, which the worker has no copy of —
|
|
88
|
+
so every worker died on unpickle. Only text crosses the boundary and only tuples come back,
|
|
89
|
+
so there is nothing here a fork has to keep consistent.'''
|
|
90
|
+
kw = dict(mp_context=get_context('fork')) if sys.platform == 'darwin' else {}
|
|
91
|
+
return ProcessPoolExecutor(nw, **kw)
|
|
92
|
+
|
|
93
|
+
def _n_workers(n, n_workers):
|
|
94
|
+
'Resolve `n_workers`: None picks by size, 0 stays serial, anything else is taken literally.'
|
|
95
|
+
if n_workers is not None: return n_workers
|
|
96
|
+
return 0 if n < MIN_PARALLEL_CHUNKS else defaults.cpus
|
|
97
|
+
|
|
98
|
+
# %% ../nbs/01_build.ipynb #bd12970f219d8213
|
|
99
|
+
class _WindowStore:
|
|
100
|
+
'''Co-occurrence windows kept in a SQLite table instead of a python list.
|
|
101
|
+
The table is TEMP: scratch for one build does not belong in the shared schema, where creating
|
|
102
|
+
and dropping it bumps the schema cookie and forces every other connection to re-prepare.'''
|
|
103
|
+
PAGE = 10_000 # windows per read; keeps the cursor closed between yields
|
|
104
|
+
def __init__(self, db, name):
|
|
105
|
+
store_attr(); self.n, self._w = 0, 0
|
|
106
|
+
with write_txn(db):
|
|
107
|
+
db.conn.execute(f'drop table if exists main.{name}') # left behind by older versions
|
|
108
|
+
db.conn.execute(f'create temp table if not exists {name} (w integer, e text)')
|
|
109
|
+
db.conn.execute(f'delete from {name}')
|
|
110
|
+
def extend(self, wins):
|
|
111
|
+
rows = [(self._w + i, e) for i, w in enumerate(wins) for e in w if e]
|
|
112
|
+
if not rows: self._w += len(wins); self.n += len(wins); return
|
|
113
|
+
with write_txn(self.db): self.db.conn.executemany(f'insert into {self.name} values (?,?)', rows)
|
|
114
|
+
self._w += len(wins); self.n += len(wins)
|
|
115
|
+
def entity_counts(self):
|
|
116
|
+
'entity -> windows containing it. Bounded by vocabulary, which saturates; safe in memory.'
|
|
117
|
+
with db_lock(self.db):
|
|
118
|
+
return dict(self.db.conn.execute(f'select e, count(*) from {self.name} group by e'))
|
|
119
|
+
def pair_counts(self, hub, min_n):
|
|
120
|
+
"""Co-occurrence count per unordered pair, aggregated on disk. Materialised: the caller
|
|
121
|
+
builds edges between rows, and an open cursor makes the connection busy for that whole walk."""
|
|
122
|
+
c = self.db.conn
|
|
123
|
+
with write_txn(self.db):
|
|
124
|
+
c.execute(f'create index if not exists {self.name}_w on {self.name}(w)')
|
|
125
|
+
c.execute(f'create temp table if not exists {self.name}_hub (e text primary key)')
|
|
126
|
+
c.execute(f'delete from {self.name}_hub')
|
|
127
|
+
if hub: c.executemany(f'insert or ignore into {self.name}_hub values (?)', [(e,) for e in hub])
|
|
128
|
+
with db_lock(self.db): return c.execute(f'''select a.e, b.e, count(*) c from {self.name} a
|
|
129
|
+
join {self.name} b on a.w = b.w and a.e < b.e
|
|
130
|
+
where a.e not in (select e from {self.name}_hub)
|
|
131
|
+
and b.e not in (select e from {self.name}_hub)
|
|
132
|
+
group by a.e, b.e having c >= {int(min_n)}''').fetchall()
|
|
133
|
+
def __len__(self): return self.n
|
|
134
|
+
def __iter__(self):
|
|
135
|
+
'One page of whole windows at a time: a cursor left open across a yield holds the connection.'
|
|
136
|
+
last = -1
|
|
137
|
+
while True:
|
|
138
|
+
with db_lock(self.db):
|
|
139
|
+
ws = [r[0] for r in self.db.conn.execute(
|
|
140
|
+
f'select distinct w from {self.name} where w > ? order by w limit {self.PAGE}', (last,)).fetchall()]
|
|
141
|
+
if not ws: return
|
|
142
|
+
rows = self.db.conn.execute(f'select w, e from {self.name} where w between ? and ? order by w',
|
|
143
|
+
(ws[0], ws[-1])).fetchall()
|
|
144
|
+
w, acc = None, set()
|
|
145
|
+
for wi, e in rows:
|
|
146
|
+
if wi != w:
|
|
147
|
+
if w is not None: yield acc
|
|
148
|
+
w, acc = wi, set()
|
|
149
|
+
acc.add(e)
|
|
150
|
+
if w is not None: yield acc
|
|
151
|
+
last = ws[-1]
|
|
152
|
+
def drop(self):
|
|
153
|
+
with write_txn(self.db):
|
|
154
|
+
self.db.conn.execute(f'drop table if exists temp.{self.name}')
|
|
155
|
+
self.db.conn.execute(f'drop table if exists temp.{self.name}_hub')
|
|
156
|
+
|
|
157
|
+
def build_graph(db, # Database with a chunk store
|
|
158
|
+
chunks, # chunk dicts ({'content','metadata'}) as returned by dir2chunks/pkg2chunks
|
|
159
|
+
store='store', # chunk store name
|
|
160
|
+
prefix=None, # graph table prefix
|
|
161
|
+
terms_fn=None, # (text, topk) -> terms, replacing yake (see `sanskrit_terms`)
|
|
162
|
+
emb_fn=None, # embedder for entity names (required for resolve_entities)
|
|
163
|
+
code=True, # extract AST symbols from code chunks
|
|
164
|
+
prose=True, # extract surfaces from prose chunks
|
|
165
|
+
cooc=True, # also write PMI co-occurrence edges over sentence windows
|
|
166
|
+
min_n=2, # min co-occurrence count
|
|
167
|
+
min_npmi=0.15, # min normalized PMI
|
|
168
|
+
max_df=0.4, # drop entities present in >max_df of windows
|
|
169
|
+
max_degree=48, # max cooc edges kept per node
|
|
170
|
+
batch:int=None, # chunks per flush; keeps windows on disk instead of in memory
|
|
171
|
+
n_workers:int=None): # extraction workers; 0 is serial, None picks by queue size
|
|
172
|
+
'''Extract entities + mentions + edges (exact for code, PMI co-occurrence for prose) from chunks.
|
|
173
|
+
|
|
174
|
+
`batch` is what makes a large corpus finish. Left as None everything accumulates in memory for
|
|
175
|
+
the whole call, which is fine for a package or a few thousand pages and is not fine for a
|
|
176
|
+
corpus: windows are one per sentence and never stop arriving, so memory tracks the corpus with
|
|
177
|
+
no bound. Set it and mentions are flushed every `batch` chunks and windows go to a scratch
|
|
178
|
+
table, which `_pmi_edges` reads back twice exactly as it would a list. The edges are identical
|
|
179
|
+
either way — the batched path is a different place to keep the same numbers.
|
|
180
|
+
|
|
181
|
+
`n_workers` spreads extraction across processes, which is where a build spends 88% of its time.
|
|
182
|
+
Use it with `batch` large enough to be worth a pool: the queue is drained once per batch, so a
|
|
183
|
+
small `batch` pays pool startup repeatedly for very little work.'''
|
|
184
|
+
g = db.get_graph(store, prefix)
|
|
185
|
+
ents, mens, edges = {}, {}, {}
|
|
186
|
+
wins = _WindowStore(db, f'{g.prefix}_win_scratch') if batch else []
|
|
187
|
+
def ent(name, kind):
|
|
188
|
+
'Register an entity by canonical name, returning its hash id.'
|
|
189
|
+
n = _norm(name)
|
|
190
|
+
if not n: return None
|
|
191
|
+
i = content_id(n)
|
|
192
|
+
e = ents.setdefault(i, dict(content=n, kind=kind, freq=0, canon=i))
|
|
193
|
+
e['freq'] += 1
|
|
194
|
+
return i
|
|
195
|
+
def men(cid, eid, surface):
|
|
196
|
+
if not (cid and eid): return
|
|
197
|
+
m = mens.setdefault((cid, eid), dict(chunk_id=cid, entity_id=eid, surface=surface, n=0))
|
|
198
|
+
m['n'] += 1
|
|
199
|
+
def edge(s, d, rel, w=1.0):
|
|
200
|
+
if not (s and d) or s == d: return
|
|
201
|
+
e = edges.setdefault((s, d, rel), dict(src=s, dst=d, rel=rel, weight=0.0, n=0))
|
|
202
|
+
e['weight'] += w; e['n'] += 1
|
|
203
|
+
n_mens = 0
|
|
204
|
+
def flush_mentions():
|
|
205
|
+
'Mentions are complete once their chunk is processed, so they need not wait for the corpus.'
|
|
206
|
+
nonlocal n_mens
|
|
207
|
+
if not mens: return
|
|
208
|
+
upsert_all(g.mentions, mens.values(), ('chunk_id','entity_id'))
|
|
209
|
+
n_mens += len(mens); mens.clear()
|
|
210
|
+
|
|
211
|
+
pend = [] # windows waiting for the next flush
|
|
212
|
+
def add_wins(ws):
|
|
213
|
+
if batch: pend.extend(ws)
|
|
214
|
+
else: wins.extend(ws)
|
|
215
|
+
|
|
216
|
+
prose_q, pool = [], None
|
|
217
|
+
def prose_wins():
|
|
218
|
+
'''Windows per queued prose chunk, over a process pool once there are enough to pay for one.
|
|
219
|
+
Order is preserved by both pools, and the reduce depends on it: `ents.setdefault` keeps the
|
|
220
|
+
`kind` of an entity's *first* mention, so a reordered stream would relabel entities.'''
|
|
221
|
+
nonlocal pool
|
|
222
|
+
nw = 0 if terms_fn is not None else _n_workers(len(prose_q), n_workers)
|
|
223
|
+
if nw and nw > 1:
|
|
224
|
+
if pool is None: pool = _pool(nw)
|
|
225
|
+
# both sides materialised: `drain_prose` clears the queue these are drawn from
|
|
226
|
+
cids, txts = [c for c, _ in prose_q], [t for _, t in prose_q]
|
|
227
|
+
return zip(cids, pool.map(_prose_job, txts))
|
|
228
|
+
return ((cid, prose_windows(txt, terms_fn=terms_fn)) for cid, txt in prose_q)
|
|
229
|
+
|
|
230
|
+
def drain_prose():
|
|
231
|
+
'Turn the queued prose chunks into entities, mentions and windows, then clear the queue.'
|
|
232
|
+
for cid, wl in prose_wins():
|
|
233
|
+
for win in wl:
|
|
234
|
+
w = set()
|
|
235
|
+
for surf, kind in win:
|
|
236
|
+
i = ent(surf, kind)
|
|
237
|
+
if i: men(cid, i, surf); w.add(i)
|
|
238
|
+
if len(w) > 1: add_wins([w])
|
|
239
|
+
prose_q.clear()
|
|
240
|
+
|
|
241
|
+
n_seen = 0
|
|
242
|
+
try:
|
|
243
|
+
for c in ([chunks] if isinstance(chunks, dict) else chunks):
|
|
244
|
+
txt = c.get('content')
|
|
245
|
+
if not (txt and txt.strip()): continue
|
|
246
|
+
cid = c.get('id') or content_id(txt)
|
|
247
|
+
if code and _is_code(c):
|
|
248
|
+
dname, calls, imps = code_entities(c)
|
|
249
|
+
did = ent(dname, 'symbol') if dname else None
|
|
250
|
+
if did: men(cid, did, dname)
|
|
251
|
+
w = {did} if did else set()
|
|
252
|
+
for nm in calls:
|
|
253
|
+
i = ent(nm, 'symbol')
|
|
254
|
+
men(cid, i, nm)
|
|
255
|
+
edge(did, i, 'calls')
|
|
256
|
+
w.add(i)
|
|
257
|
+
for nm in imps:
|
|
258
|
+
i = ent(nm, 'module')
|
|
259
|
+
men(cid, i, nm)
|
|
260
|
+
edge(did, i, 'imports')
|
|
261
|
+
w.add(i)
|
|
262
|
+
if len(w) > 1: add_wins([w - {None}])
|
|
263
|
+
elif prose: prose_q.append((cid, txt))
|
|
264
|
+
n_seen += 1
|
|
265
|
+
if batch and n_seen % batch == 0:
|
|
266
|
+
drain_prose()
|
|
267
|
+
wins.extend(pend)
|
|
268
|
+
pend.clear()
|
|
269
|
+
flush_mentions()
|
|
270
|
+
drain_prose()
|
|
271
|
+
finally:
|
|
272
|
+
if pool is not None: pool.shutdown()
|
|
273
|
+
if batch: wins.extend(pend); pend.clear()
|
|
274
|
+
rows = list(ents.values())
|
|
275
|
+
if cooc and len(wins):
|
|
276
|
+
for e in _pmi_edges(wins, min_n, min_npmi, max_df, max_degree): edges[(e['src'], e['dst'], e['rel'])] = e
|
|
277
|
+
n_wins = len(wins)
|
|
278
|
+
if batch: wins.drop()
|
|
279
|
+
with write_txn(db):
|
|
280
|
+
if rows:
|
|
281
|
+
if emb_fn: process_content(g.entities, rows, embed=True, emb_fn=emb_fn)
|
|
282
|
+
else: g.entities.insert_all(rows, upsert=True, hash_id='id', hash_id_columns=['content'])
|
|
283
|
+
if mens:
|
|
284
|
+
upsert_all(g.mentions, mens.values(), ('chunk_id','entity_id')); n_mens += len(mens)
|
|
285
|
+
if edges: upsert_all(g.edges, edges.values(), ('src','dst','rel'))
|
|
286
|
+
if emb_fn and rows: g.entities.rebuild_index()
|
|
287
|
+
return dict(entities=len(rows), mentions=n_mens, edges=len(edges), windows=n_wins)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# %% ../nbs/01_build.ipynb #f1e0bc8fd1ac5d91
|
|
291
|
+
_EXACT_KINDS = ('symbol', 'module', 'topic') # names that are already canonical — never merge these
|
|
292
|
+
|
|
293
|
+
def _uf_find(par, x):
|
|
294
|
+
while par[x] != x: par[x] = par[par[x]]; x = par[x]
|
|
295
|
+
return x
|
|
296
|
+
|
|
297
|
+
def _uf_union(par, rank, a, b, name=None, ok=None, members=None, max_check=32):
|
|
298
|
+
'Merge two groups. With `name`/`ok`, only if the merged group stays a **clique** under `ok`.'
|
|
299
|
+
ra, rb = _uf_find(par, a), _uf_find(par, b)
|
|
300
|
+
if ra == rb: return False
|
|
301
|
+
if name is not None and ok is not None:
|
|
302
|
+
A = (members.get(ra, [ra]) if members else [ra])[:max_check]
|
|
303
|
+
B = (members.get(rb, [rb]) if members else [rb])[:max_check]
|
|
304
|
+
if not all(ok(name[x], name[y]) for x in A for y in B): return False
|
|
305
|
+
if rank[ra] < rank[rb]: ra, rb = rb, ra
|
|
306
|
+
par[rb] = ra
|
|
307
|
+
if members is not None: members.setdefault(ra, [ra]).extend(members.pop(rb, [rb]))
|
|
308
|
+
return True
|
|
309
|
+
|
|
310
|
+
def _ann_pairs(tbl, rows, k=8, dtype=np.float16):
|
|
311
|
+
'''`(id, neighbour_id, distance)` for every embedded row, from one batched HNSW probe.
|
|
312
|
+
|
|
313
|
+
usearch searches a matrix of queries across its own thread pool, so asking it once for 8,487
|
|
314
|
+
vectors is not the same work as asking it 8,487 times: the per-entity loop also ran one
|
|
315
|
+
`rowid IN (...)` query per entity, and the two together were 10.3s of a 23.0s resolve. The
|
|
316
|
+
candidate set is unchanged — same k, same neighbours, same distances.'''
|
|
317
|
+
idx_ = tbl.db.get_index(tbl.name)
|
|
318
|
+
emb = [r for r in rows if r['embedding']]
|
|
319
|
+
if not emb or not idx_.size: return
|
|
320
|
+
key = {r['rowid']: r for r in tbl.db.q(f'select {rowid_sel()}, id, content from {tbl.name}')}
|
|
321
|
+
res = idx_.search(np.stack([np.frombuffer(r['embedding'], dtype=dtype) for r in emb]),
|
|
322
|
+
count=min(k, idx_.size))
|
|
323
|
+
ks, ds = np.atleast_2d(res.keys), np.atleast_2d(res.distances)
|
|
324
|
+
for r, kr, dr in zip(emb, ks, ds):
|
|
325
|
+
for kk, dd in zip(np.atleast_1d(kr).tolist(), np.atleast_1d(dr).tolist()):
|
|
326
|
+
if (o := key.get(int(kk))): yield r, o, float(dd)
|
|
327
|
+
|
|
328
|
+
def _lexical_pairs(name, max_group=60):
|
|
329
|
+
'''Candidate merge pairs by shared-token blocking — catches containment variants
|
|
330
|
+
|
|
331
|
+
Tokens are walked in sorted order, which is what makes a resolve reproducible. `_toks` returns
|
|
332
|
+
a frozenset, so its iteration order follows string hashes and therefore `PYTHONHASHSEED`; that
|
|
333
|
+
decided the insertion order of `inv`, which decided the order pairs were proposed in, and
|
|
334
|
+
`_uf_union` only accepts a merge that keeps the group a clique — an order-dependent test. Two
|
|
335
|
+
resolves of the *same* database in two processes came back with different partitions and merge
|
|
336
|
+
counts drifting over a range of three, which looked like HNSW noise and was not: hold the seed
|
|
337
|
+
still and both the old code and the new one are exactly reproducible, and `verify_ann_probe`
|
|
338
|
+
says the ANN candidates were stable the whole time.'''
|
|
339
|
+
inv = {}
|
|
340
|
+
for i, s in name.items():
|
|
341
|
+
for t in sorted(_toks(s)):
|
|
342
|
+
if len(t) > 2: inv.setdefault(t, []).append(i)
|
|
343
|
+
seen = set()
|
|
344
|
+
for ids in inv.values():
|
|
345
|
+
if len(ids) < 2: continue
|
|
346
|
+
if len(ids) <= max_group: pairs = ((ids[a], ids[b]) for a in range(len(ids)) for b in range(a+1, len(ids)))
|
|
347
|
+
else:
|
|
348
|
+
srt = sorted(ids, key=lambda i: name[i])
|
|
349
|
+
pairs = ((srt[a], srt[b]) for a in range(len(srt)) for b in range(a+1, min(a+max_group, len(srt))))
|
|
350
|
+
for x, y in pairs:
|
|
351
|
+
p = (x, y) if x < y else (y, x)
|
|
352
|
+
if p not in seen: seen.add(p); yield p
|
|
353
|
+
|
|
354
|
+
def resolve_entities(db, # Database
|
|
355
|
+
store='store', # chunk store the graph belongs to
|
|
356
|
+
prefix=None, # graph table prefix
|
|
357
|
+
thresh=0.18, # max ANN distance for a merge candidate
|
|
358
|
+
lex=0.34, # min token Jaccard for the lexical guard
|
|
359
|
+
k=8, # ANN neighbours considered per entity
|
|
360
|
+
lexical=True, # also block on shared tokens (works without embeddings)
|
|
361
|
+
max_group=60, # skip token groups bigger than this in the lexical pass
|
|
362
|
+
skip_kinds=_EXACT_KINDS, # kinds whose names are already canonical
|
|
363
|
+
dtype=np.float16):
|
|
364
|
+
'Merge near-duplicate entities: ANN + shared-token candidates, both gated by the lexical guard.'
|
|
365
|
+
g = db.get_graph(store, prefix)
|
|
366
|
+
allr = L(g.entities(select=f'{rowid_sel()}, id, content, freq, embedding, kind, canon'))
|
|
367
|
+
rows = allr.filter(lambda r: r['kind'] not in set(skip_kinds or ()))
|
|
368
|
+
if len(rows) < 2:
|
|
369
|
+
return dict(merged=0, by_ann=0, by_lexical=0, edges=len(list(g.edges())),
|
|
370
|
+
entities=len(allr), resolvable=len(rows), canonical=len(allr))
|
|
371
|
+
par = {r['id']: r['id'] for r in rows}
|
|
372
|
+
rank = {r['id']: (r['freq'] or 0) for r in rows}
|
|
373
|
+
name = {r['id']: r['content'] for r in rows}
|
|
374
|
+
ann_m = lex_m = 0
|
|
375
|
+
guard, members = (lambda x, y: _lex_ok(x, y, lex)), {}
|
|
376
|
+
for r, h, dist in _ann_pairs(g.entities, rows, k, dtype):
|
|
377
|
+
oid = h.get('id')
|
|
378
|
+
if not oid or oid == r['id'] or oid not in par: continue
|
|
379
|
+
if dist > thresh: continue
|
|
380
|
+
if not _lex_ok(r['content'], h['content'], lex): continue
|
|
381
|
+
if _uf_union(par, rank, r['id'], oid, name, guard, members): ann_m += 1
|
|
382
|
+
if lexical:
|
|
383
|
+
for a, b in _lexical_pairs(name, max_group):
|
|
384
|
+
if _lex_ok(name[a], name[b], lex) and _uf_union(par, rank, a, b, name, guard, members): lex_m += 1
|
|
385
|
+
upd = [(_uf_find(par, i), i) for i in par]
|
|
386
|
+
was = {r['id']: r['canon'] for r in rows}
|
|
387
|
+
chg = [(c, i) for c, i in upd if c != was.get(i)]
|
|
388
|
+
if chg:
|
|
389
|
+
with write_txn(db):
|
|
390
|
+
db.conn.cursor().executemany(f'update {g.entities.name} set canon=? where id=?', chg)
|
|
391
|
+
canon = {i: c for c, i in upd}
|
|
392
|
+
n_edges = _collapse_edges(db, g, canon)
|
|
393
|
+
skipped = len(allr) - len(rows)
|
|
394
|
+
return dict(merged=ann_m+lex_m, by_ann=ann_m, by_lexical=lex_m, edges=n_edges,
|
|
395
|
+
entities=len(allr), resolvable=len(rows),
|
|
396
|
+
canonical=len({c for c, _ in upd}) + skipped)
|
|
397
|
+
|
|
398
|
+
def _collapse_edges(db, g, canon):
|
|
399
|
+
'Rewrite edge endpoints onto canonical ids. Traversal reads src/dst straight from the table,'
|
|
400
|
+
rows = list(g.edges())
|
|
401
|
+
if not rows: return 0
|
|
402
|
+
agg = {}
|
|
403
|
+
for r in rows:
|
|
404
|
+
s, d = canon.get(r['src'], r['src']), canon.get(r['dst'], r['dst'])
|
|
405
|
+
if s == d: continue
|
|
406
|
+
if s > d and r['rel'] == 'cooc': s, d = d, s # cooc is symmetric; keep one direction
|
|
407
|
+
k = (s, d, r['rel'])
|
|
408
|
+
a = agg.setdefault(k, dict(src=s, dst=d, rel=r['rel'], weight=0.0, n=0))
|
|
409
|
+
a['weight'] = max(a['weight'], r['weight'] or 0.0); a['n'] += (r['n'] or 0)
|
|
410
|
+
with write_txn(db):
|
|
411
|
+
g.edges.delete_where()
|
|
412
|
+
upsert_all(g.edges, agg.values(), ('src','dst','rel'))
|
|
413
|
+
return len(agg)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
# %% ../nbs/01_build.ipynb #dd02940510f58745
|
|
417
|
+
def cooccur_edges(db, # Database
|
|
418
|
+
store='store', # chunk store
|
|
419
|
+
prefix=None, # graph table prefix
|
|
420
|
+
min_n=2, # min co-occurrence count
|
|
421
|
+
min_npmi=0.15, # min normalized PMI (prunes stopword-ish hub nodes)
|
|
422
|
+
max_df=0.4, # drop entities present in >max_df of chunks
|
|
423
|
+
max_degree=48, # keep only the strongest edges per node
|
|
424
|
+
rel='cooc',
|
|
425
|
+
use_canon=True): # collapse to canonical ids from resolve_entities
|
|
426
|
+
'Rebuild co-occurrence edges from the stored mentions, using the chunk as the window.'
|
|
427
|
+
g = db.get_graph(store, prefix)
|
|
428
|
+
canon = {}
|
|
429
|
+
if use_canon:
|
|
430
|
+
canon = {r['id']: (r['canon'] or r['id']) for r in g.entities(select='id, canon')}
|
|
431
|
+
cid_ents = {}
|
|
432
|
+
for m in g.mentions(select='chunk_id, entity_id'):
|
|
433
|
+
e = canon.get(m['entity_id'], m['entity_id'])
|
|
434
|
+
cid_ents.setdefault(m['chunk_id'], set()).add(e)
|
|
435
|
+
out = _pmi_edges(list(cid_ents.values()), min_n, min_npmi, max_df, max_degree, rel)
|
|
436
|
+
if out: upsert_all(g.edges, out, ('src','dst','rel'))
|
|
437
|
+
return len(out)
|
|
438
|
+
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/00_entities.ipynb.
|
|
2
|
+
|
|
3
|
+
# %% auto #0
|
|
4
|
+
__all__ = ['code_entities', 'prose_windows', 'text_entities']
|
|
5
|
+
|
|
6
|
+
# %% ../nbs/00_entities.ipynb #ab82e9d12af8898f
|
|
7
|
+
import ast, math, re, sys
|
|
8
|
+
from functools import lru_cache
|
|
9
|
+
import numpy as np
|
|
10
|
+
from fastcore.all import AttrDict, L, Path, chunked, defaults, first, ifnone, merge, patch, store_attr
|
|
11
|
+
from fastcore.parallel import ProcessPoolExecutor
|
|
12
|
+
from fastlite import Database
|
|
13
|
+
from apswutils.db import Table
|
|
14
|
+
from multiprocessing import get_context
|
|
15
|
+
from litesearch.core import (sql_in, rowid_sel, content_id, NP_DTYPE, process_content, write_txn, db_lock,
|
|
16
|
+
upsert_all, rrf_all)
|
|
17
|
+
from litesearch.topics import get_graph
|
|
18
|
+
from litesearch.utils import hash_embed
|
|
19
|
+
|
|
20
|
+
# %% ../nbs/00_entities.ipynb #673ffc3a5da7b038
|
|
21
|
+
_DET = re.compile(r'^(the|a|an|this|that|these|those|its|their|our|your|his|her)\s+', re.I)
|
|
22
|
+
_WS = re.compile(r'\s+')
|
|
23
|
+
_PRON = {'it','its','we','our','they','their','he','she','you','i','me','us','them','this','that',
|
|
24
|
+
'these','those','which','who','what','there','here','one','ones','something','anything'}
|
|
25
|
+
_TOK = re.compile(r'[a-z0-9]+')
|
|
26
|
+
_NUM = re.compile(r'\d+')
|
|
27
|
+
|
|
28
|
+
# %% ../nbs/00_entities.ipynb #109cd07acfcb6b94
|
|
29
|
+
def _norm(s):
|
|
30
|
+
'Canonical surface form for a mention; None when the phrase is not entity-like.'
|
|
31
|
+
if not s: return None
|
|
32
|
+
s = _WS.sub(' ', s).strip().strip('.,;:!?()[]{}"\'`')
|
|
33
|
+
s = _DET.sub('', s).strip()
|
|
34
|
+
s = re.sub(r"'s$", '', s).strip()
|
|
35
|
+
if not (2 <= len(s) <= 60): return None
|
|
36
|
+
if len(s.split()) > 5: return None
|
|
37
|
+
if s.lower() in _PRON: return None
|
|
38
|
+
if not any(c.isalpha() for c in s): return None
|
|
39
|
+
return s.lower()
|
|
40
|
+
|
|
41
|
+
@lru_cache(maxsize=1<<17)
|
|
42
|
+
def _toks(s):
|
|
43
|
+
'Tokens for the lexical guard. UAX#29 treats `_` as a word joiner, so `fts_search` stays one'
|
|
44
|
+
try: from apsw.unicode import word_iter, casefold
|
|
45
|
+
except ImportError: return frozenset(_TOK.findall(s.lower()))
|
|
46
|
+
return frozenset(casefold(t) for t in word_iter(s or '') if any(c.isalnum() for c in t))
|
|
47
|
+
|
|
48
|
+
@lru_cache(maxsize=1<<17)
|
|
49
|
+
def _acr(s): return ''.join(w[0] for w in s.split() if w)
|
|
50
|
+
|
|
51
|
+
@lru_cache(maxsize=1<<17)
|
|
52
|
+
def _nums(s): return frozenset(_NUM.findall(s))
|
|
53
|
+
|
|
54
|
+
def _sentences(text):
|
|
55
|
+
'UAX#29 sentence split via apsw; falls back to the whole text as one window.'
|
|
56
|
+
try: from apsw.unicode import sentence_iter
|
|
57
|
+
except ImportError: return [text]
|
|
58
|
+
return [s for s in (x.strip() for x in sentence_iter(text)) if s]
|
|
59
|
+
|
|
60
|
+
def _jac(a, b):
|
|
61
|
+
A, B = _toks(a), _toks(b)
|
|
62
|
+
u = len(A) + len(B) - len(A & B)
|
|
63
|
+
return len(A & B) / u if u else 0.0
|
|
64
|
+
|
|
65
|
+
# %% ../nbs/00_entities.ipynb #7710055a5e8346fe
|
|
66
|
+
def _lex_ok(a, b, lex=0.34, cover=0.5):
|
|
67
|
+
'''Lexical guard on a proposed merge.'''
|
|
68
|
+
if a == b: return True
|
|
69
|
+
A, B = _toks(a), _toks(b)
|
|
70
|
+
ok = False
|
|
71
|
+
if A and B:
|
|
72
|
+
inter = len(A & B)
|
|
73
|
+
ok = ((inter == min(len(A), len(B)) and inter/max(len(A), len(B)) >= cover)
|
|
74
|
+
or inter/(len(A) + len(B) - inter) >= lex)
|
|
75
|
+
if not ok and not (_acr(a) == b.lower() or _acr(b) == a.lower()): return False
|
|
76
|
+
return _nums(a) == _nums(b)
|
|
77
|
+
|
|
78
|
+
# %% ../nbs/00_entities.ipynb #5952ad1336601711
|
|
79
|
+
_PY_SKIP = {'self','cls','super','print','len','str','int','float','bool','list','dict','set','tuple',
|
|
80
|
+
'range','enumerate','zip','map','filter','isinstance','getattr','setattr','hasattr','type',
|
|
81
|
+
'format','join','append','get','items','keys','values','open','sorted','sum','min','max'}
|
|
82
|
+
|
|
83
|
+
def _def_name(chunk):
|
|
84
|
+
'Symbol defined by a code chunk, from pyparse metadata or the chunk source itself.'
|
|
85
|
+
md = chunk.get('metadata') or {}
|
|
86
|
+
if isinstance(md, dict) and md.get('name'): return md['name']
|
|
87
|
+
try: tree = ast.parse(chunk['content'])
|
|
88
|
+
except SyntaxError: return None
|
|
89
|
+
n = first(tree.body, lambda x: isinstance(x, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)))
|
|
90
|
+
return getattr(n, 'name', None)
|
|
91
|
+
|
|
92
|
+
def code_entities(chunk):
|
|
93
|
+
'Exact symbols for a python chunk: (defined, called, imported). AST-derived, no model.'
|
|
94
|
+
try: tree = ast.parse(chunk['content'])
|
|
95
|
+
except (SyntaxError, ValueError): return None, L(), L()
|
|
96
|
+
calls, imps = L(), L()
|
|
97
|
+
for n in ast.walk(tree):
|
|
98
|
+
if isinstance(n, ast.Call):
|
|
99
|
+
f = n.func
|
|
100
|
+
if isinstance(f, ast.Name): calls.append(f.id)
|
|
101
|
+
elif isinstance(f, ast.Attribute): calls.append(f.attr)
|
|
102
|
+
elif isinstance(n, ast.Import):
|
|
103
|
+
imps += [a.name.split('.')[0] for a in n.names]
|
|
104
|
+
elif isinstance(n, ast.ImportFrom):
|
|
105
|
+
if n.module: imps.append(n.module.split('.')[0])
|
|
106
|
+
keep = lambda s: s and s not in _PY_SKIP and not s.startswith('_') and len(s) > 2
|
|
107
|
+
return _def_name(chunk), calls.filter(keep).unique(), imps.filter(keep).unique()
|
|
108
|
+
|
|
109
|
+
# %% ../nbs/00_entities.ipynb #d337238a913ea5d3
|
|
110
|
+
def _yake_terms(text, topk=12):
|
|
111
|
+
'Keyphrases via yake — zero model, zero labels, and the default prose extractor.'
|
|
112
|
+
try: from yake import KeywordExtractor
|
|
113
|
+
except ImportError: return L()
|
|
114
|
+
try: return L(KeywordExtractor(n=3, top=topk).extract_keywords(text)).map(lambda kv: kv[0])
|
|
115
|
+
except Exception: return L()
|
|
116
|
+
|
|
117
|
+
def prose_windows(text, # chunk text
|
|
118
|
+
topk=12, # keyphrase count
|
|
119
|
+
terms_fn=None): # (text, topk) -> terms; None -> yake
|
|
120
|
+
'Entity surfaces grouped into co-occurrence windows — one per sentence.'
|
|
121
|
+
terms = (terms_fn or _yake_terms)(text, topk)
|
|
122
|
+
if not terms: return L()
|
|
123
|
+
# the term list is fixed for the whole chunk, so it is lowercased once here rather than once
|
|
124
|
+
# per sentence — `t.lower()` sat in the inner loop and ran topk times for every sentence
|
|
125
|
+
low = [(t, t.lower()) for t in terms]
|
|
126
|
+
wins = []
|
|
127
|
+
for s in _sentences(text):
|
|
128
|
+
sl = s.lower()
|
|
129
|
+
hit = L([(t, 'keyphrase') for t, tl in low if tl in sl])
|
|
130
|
+
if hit: wins.append(hit)
|
|
131
|
+
return L(wins)
|
|
132
|
+
|
|
133
|
+
def text_entities(text, **kw):
|
|
134
|
+
'Entity surfaces for a prose chunk, flattened across windows. Returns L of (surface, kind).'
|
|
135
|
+
seen, out = set(), L()
|
|
136
|
+
for w in prose_windows(text, **kw):
|
|
137
|
+
for s, k in w:
|
|
138
|
+
n = _norm(s)
|
|
139
|
+
if n and n not in seen: seen.add(n); out.append((s, k))
|
|
140
|
+
return out
|
|
141
|
+
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/02_search.ipynb.
|
|
2
|
+
|
|
3
|
+
# %% auto #0
|
|
4
|
+
__all__ = ['graph_stats']
|
|
5
|
+
|
|
6
|
+
# %% ../nbs/02_search.ipynb #ab82e9d12af8898f
|
|
7
|
+
import ast, math, re, sys
|
|
8
|
+
from functools import lru_cache
|
|
9
|
+
import numpy as np
|
|
10
|
+
from fastcore.all import AttrDict, L, Path, chunked, defaults, first, ifnone, merge, patch, store_attr
|
|
11
|
+
from fastcore.parallel import ProcessPoolExecutor
|
|
12
|
+
from fastlite import Database
|
|
13
|
+
from apswutils.db import Table
|
|
14
|
+
from multiprocessing import get_context
|
|
15
|
+
from litesearch.core import (sql_in, rowid_sel, content_id, NP_DTYPE, process_content, write_txn, db_lock,
|
|
16
|
+
upsert_all, rrf_all)
|
|
17
|
+
from litesearch.topics import get_graph
|
|
18
|
+
from litesearch.utils import hash_embed
|
|
19
|
+
from .build import build_graph, resolve_entities, cooccur_edges
|
|
20
|
+
|
|
21
|
+
# %% ../nbs/02_search.ipynb #a380070d2dd9d9da
|
|
22
|
+
def _adjacency(g, nodes, hops=2, max_nodes=4000):
|
|
23
|
+
'''BFS the edge table out to `hops` from `nodes`; returns an undirected dict-of-dict adjacency.
|
|
24
|
+
|
|
25
|
+
Read as raw tuples rather than through the table wrapper. A two-hop walk off twelve seeds pulls
|
|
26
|
+
~16k edges, and fastlite turns each one into a dict with a description lookup per row — 326k
|
|
27
|
+
dicts over twenty queries, of which this needs three columns and none of the keys.'''
|
|
28
|
+
adj, seen, frontier = {}, set(nodes), set(nodes)
|
|
29
|
+
con, tbl = g.edges.db.conn, g.edges.name
|
|
30
|
+
for _ in range(max(hops, 0)):
|
|
31
|
+
if not frontier or len(seen) >= max_nodes: break
|
|
32
|
+
fl = list(frontier)
|
|
33
|
+
rows = []
|
|
34
|
+
for b in chunked(fl, 400):
|
|
35
|
+
rows += con.execute(f"select src, dst, weight from {tbl} "
|
|
36
|
+
f"where {sql_in('src', b)} OR {sql_in('dst', b)}").fetchall()
|
|
37
|
+
nxt = set()
|
|
38
|
+
for s, d, w in rows:
|
|
39
|
+
w = w or 1.0
|
|
40
|
+
adj.setdefault(s, {})[d] = max(adj.setdefault(s, {}).get(d, 0.0), w)
|
|
41
|
+
adj.setdefault(d, {})[s] = max(adj.setdefault(d, {}).get(s, 0.0), w)
|
|
42
|
+
for x in (s, d):
|
|
43
|
+
if x not in seen: nxt.add(x)
|
|
44
|
+
seen |= nxt
|
|
45
|
+
frontier = nxt
|
|
46
|
+
return adj
|
|
47
|
+
|
|
48
|
+
def _csr(adj, order):
|
|
49
|
+
'''`(src, dst, w)` index arrays for `adj`, row-normalised — the transition matrix as three arrays.
|
|
50
|
+
|
|
51
|
+
Built once and reused by every power iteration, which is the whole point: the walk itself is
|
|
52
|
+
then twelve numpy passes instead of twelve nested python loops over the same unchanging edges.'''
|
|
53
|
+
src, dst, wt = [], [], []
|
|
54
|
+
for u, nb in adj.items():
|
|
55
|
+
iu, s = order[u], (sum(nb.values()) or 1.0)
|
|
56
|
+
for v, w in nb.items(): src.append(iu); dst.append(order[v]); wt.append(w/s)
|
|
57
|
+
return (np.array(src, dtype=np.intp), np.array(dst, dtype=np.intp),
|
|
58
|
+
np.array(wt, dtype=np.float64))
|
|
59
|
+
|
|
60
|
+
def _ppr(adj, seeds, damping=0.85, iters=12):
|
|
61
|
+
'''Personalized PageRank over a dict-of-dict adjacency.
|
|
62
|
+
|
|
63
|
+
The iteration is `r <- d * Wᵀr + (1-d) * p0` and nothing but `r` changes between rounds, so the
|
|
64
|
+
edges are flattened into index arrays once and each round becomes a gather plus a `bincount` —
|
|
65
|
+
the same sum, done by numpy instead of by a dict lookup per edge per round. Dangling nodes still
|
|
66
|
+
drop their mass rather than redistributing it, exactly as the dict version did.'''
|
|
67
|
+
if not seeds: return {}
|
|
68
|
+
order = {}
|
|
69
|
+
for u, nb in adj.items():
|
|
70
|
+
if u not in order: order[u] = len(order)
|
|
71
|
+
for v in nb:
|
|
72
|
+
if v not in order: order[v] = len(order)
|
|
73
|
+
for k in seeds:
|
|
74
|
+
if k not in order: order[k] = len(order)
|
|
75
|
+
n = len(order)
|
|
76
|
+
p0 = np.zeros(n)
|
|
77
|
+
tot = sum(seeds.values()) or 1.0
|
|
78
|
+
for k, v in seeds.items(): p0[order[k]] = v/tot
|
|
79
|
+
src, dst, wt = _csr(adj, order)
|
|
80
|
+
wt = wt * damping
|
|
81
|
+
r, rest = p0, (1-damping)*p0
|
|
82
|
+
for _ in range(iters):
|
|
83
|
+
r = np.bincount(dst, weights=r[src]*wt, minlength=n) + rest
|
|
84
|
+
return {k: float(r[i]) for k, i in order.items() if r[i]}
|
|
85
|
+
|
|
86
|
+
# %% ../nbs/02_search.ipynb #66f98425ed3e448
|
|
87
|
+
def _canon_mentions(g, # graph tables from `get_graph`
|
|
88
|
+
col, # 'chunk_id' or 'entity_id' — the side being filtered
|
|
89
|
+
vals, # values to filter on
|
|
90
|
+
use_canon=True, # resolve entity_id to its canonical id
|
|
91
|
+
batch=400):
|
|
92
|
+
'''`(chunk_id, entity_id)` for the matching mentions, with `entity_id` already canonicalised.'''
|
|
93
|
+
con, mn, en = g.mentions.db.conn, g.mentions.name, g.entities.name
|
|
94
|
+
sel = (f'select m.chunk_id, coalesce(nullif(e.canon, \'\'), m.entity_id) from {mn} m '
|
|
95
|
+
f'left join {en} e on e.id = m.entity_id') if use_canon else \
|
|
96
|
+
f'select m.chunk_id, m.entity_id from {mn} m'
|
|
97
|
+
out = []
|
|
98
|
+
for b in chunked(vals, batch):
|
|
99
|
+
out += con.execute(f"{sel} where {sql_in('m.'+col, b)}").fetchall()
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
# %% ../nbs/02_search.ipynb #fba9515fca9c5b56
|
|
103
|
+
def _leg(db, g, seeds_from, cols, table_name, limit, hops, damping, iters, use_canon):
|
|
104
|
+
'Chunks the walk reaches, ranked by PPR mass. Empty at every dead end, which RRF then ignores.'
|
|
105
|
+
if not seeds_from: return []
|
|
106
|
+
# canon is resolved by joining the mentions being read rather than by loading the whole entity
|
|
107
|
+
# table into a dict. Every query paid O(entities) for a map it used O(mentions-of-12-chunks) of,
|
|
108
|
+
# which is the one cost here that grew with the corpus rather than with the query.
|
|
109
|
+
seeds = {}
|
|
110
|
+
for _, e in _canon_mentions(g, 'chunk_id', seeds_from, use_canon): seeds[e] = seeds.get(e, 0.0) + 1.0
|
|
111
|
+
if not seeds: return []
|
|
112
|
+
mass = _ppr(_adjacency(g, set(seeds), hops), seeds, damping, iters)
|
|
113
|
+
eids = [e for e, m in sorted(mass.items(), key=lambda kv: -kv[1])[:200] if m > 0]
|
|
114
|
+
if not eids: return []
|
|
115
|
+
inv = {}
|
|
116
|
+
for cid, e in _canon_mentions(g, 'entity_id', eids, use_canon):
|
|
117
|
+
inv[cid] = inv.get(cid, 0.0) + mass.get(e, 0.0)
|
|
118
|
+
if not inv: return []
|
|
119
|
+
ranked = sorted(inv.items(), key=lambda kv: -kv[1])[:limit*3]
|
|
120
|
+
sel = ','.join([rowid_sel() if c == 'rowid' else c for c in cols])
|
|
121
|
+
rows = {r['id']: r for r in db.t[table_name](select=sel, where=sql_in('id', [c for c, _ in ranked]))}
|
|
122
|
+
return [rows[c] for c, _ in ranked if c in rows]
|
|
123
|
+
|
|
124
|
+
@patch
|
|
125
|
+
def graph_search(self:Database,
|
|
126
|
+
q:str, # query string
|
|
127
|
+
emb:bytes, # query embedding
|
|
128
|
+
columns:list=None, # columns to return
|
|
129
|
+
limit:int=20, # max results
|
|
130
|
+
table_name='store', # chunk store
|
|
131
|
+
prefix=None, # graph table prefix
|
|
132
|
+
seed_n:int=12, # hybrid hits used to seed the graph walk
|
|
133
|
+
hops:int=2, # edge-table BFS depth
|
|
134
|
+
damping:float=0.85, # PPR damping
|
|
135
|
+
iters:int=10, # PPR iterations
|
|
136
|
+
graph_w:float=0.5, # weight of the graph leg, low by default. See the docstring.
|
|
137
|
+
rrf_k:int=60,
|
|
138
|
+
use_canon=True,
|
|
139
|
+
**kw): # forwarded to Database.search
|
|
140
|
+
'Hybrid search plus a graph leg: PPR over the entity graph seeded by the top hybrid hits.'
|
|
141
|
+
g = self.get_graph(table_name, prefix)
|
|
142
|
+
cols = list(columns or [])
|
|
143
|
+
if 'rowid' not in cols: cols = ['rowid'] + cols
|
|
144
|
+
if 'id' not in cols: cols = cols + ['id']
|
|
145
|
+
base = self.search(q, emb, columns=cols, limit=max(seed_n*3, limit), table_name=table_name,
|
|
146
|
+
rrf=False, **kw)
|
|
147
|
+
if not base: return []
|
|
148
|
+
fts, vec = base['fts'], base['vec']
|
|
149
|
+
seeds = [r['id'] for r in rrf_all([fts, vec], rrf_k, seed_n) if r.get('id')]
|
|
150
|
+
leg = _leg(self, g, seeds, cols, table_name, limit, hops, damping, iters, use_canon)
|
|
151
|
+
return rrf_all([fts, vec, leg], rrf_k, limit, weights=[1.0, 1.0, graph_w])
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# %% ../nbs/02_search.ipynb #96b2124dafe08d01
|
|
155
|
+
def graph_stats(db, store='store', prefix=None):
|
|
156
|
+
'Row counts and top-degree nodes for a built graph.'
|
|
157
|
+
g = db.get_graph(store, prefix)
|
|
158
|
+
ne = first(db.q(f'select count(*) c from {g.entities.name}'))['c']
|
|
159
|
+
nm = first(db.q(f'select count(*) c from {g.mentions.name}'))['c']
|
|
160
|
+
ng = first(db.q(f'select count(*) c from {g.edges.name}'))['c']
|
|
161
|
+
nc = first(db.q(f'select count(distinct canon) c from {g.entities.name}'))['c']
|
|
162
|
+
top = db.q(f'''select e.content, e.kind, count(*) d from {g.edges.name} g
|
|
163
|
+
join {g.entities.name} e on e.id=g.src group by g.src order by d desc limit 10''')
|
|
164
|
+
return dict(entities=ne, canonical=nc, mentions=nm, edges=ng, top_degree=top)
|