codepulse-engine 0.1.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.
- codepulse/__init__.py +5 -0
- codepulse/api/app.py +1336 -0
- codepulse/api/errors.py +172 -0
- codepulse/api/security.py +189 -0
- codepulse/auth.py +406 -0
- codepulse/cli.py +1052 -0
- codepulse/config.py +475 -0
- codepulse/data_location.py +78 -0
- codepulse/embeddings/chunker.py +138 -0
- codepulse/embeddings/embedder.py +120 -0
- codepulse/git_diff.py +201 -0
- codepulse/indexer/engine.py +2655 -0
- codepulse/indexer/ignore.py +120 -0
- codepulse/ingestion_safety.py +169 -0
- codepulse/jobs.py +257 -0
- codepulse/mcp_server.py +269 -0
- codepulse/models.py +140 -0
- codepulse/observability.py +130 -0
- codepulse/parser/__init__.py +8 -0
- codepulse/parser/base.py +58 -0
- codepulse/parser/factory.py +37 -0
- codepulse/parser/graph.py +502 -0
- codepulse/parser/javascript.py +428 -0
- codepulse/parser/module_graph.py +232 -0
- codepulse/parser/python.py +331 -0
- codepulse/rate_limit.py +142 -0
- codepulse/repository_identity.py +83 -0
- codepulse/search/bm25.py +342 -0
- codepulse/search/ranker.py +382 -0
- codepulse/search/tokenizer.py +80 -0
- codepulse/search/vector.py +357 -0
- codepulse/storage/db.py +526 -0
- codepulse/storage/migrations.py +347 -0
- codepulse/storage/schema_migrations/README +1 -0
- codepulse/storage/schema_migrations/env.py +70 -0
- codepulse/storage/schema_migrations/script.py.mako +28 -0
- codepulse/storage/schema_migrations/versions/135a7b064ea7_add_base_classes_and_relationship_type.py +82 -0
- codepulse/storage/schema_migrations/versions/20fc2d0607e2_add_file_content_hash_and_natural_key_.py +106 -0
- codepulse/storage/schema_migrations/versions/21a7cb7f1a85_initial_schema.py +105 -0
- codepulse/storage/schema_migrations/versions/2cd38eb12a7e_add_call_edge_repo_id_isolation_fix.py +112 -0
- codepulse/storage/schema_migrations/versions/3ba396e9c78f_add_symbol_natural_key_uniqueness.py +80 -0
- codepulse/storage/schema_migrations/versions/49572f834366_add_embedding_persistence_fields.py +102 -0
- codepulse/storage/schema_migrations/versions/7c3e5a91d2f4_add_module_edges.py +61 -0
- codepulse/storage/schema_migrations/versions/a41f0d2c6b8e_canonicalize_repository_urls.py +94 -0
- codepulse/storage/schema_migrations/versions/b0bdd63949f4_add_api_key_model.py +71 -0
- codepulse/storage/schema_migrations/versions/b9014c049e33_add_api_key_repository_role.py +65 -0
- codepulse/storage/schema_migrations/versions/c286d277ea21_add_repository_snapshot_model.py +62 -0
- codepulse/storage/schema_migrations/versions/de22df2fc3ce_add_index_job_model.py +59 -0
- codepulse/storage/schema_migrations/versions/f6e481353553_rename_dependency_to_call_edge_add_confidence.py +80 -0
- codepulse/symbol_identity.py +29 -0
- codepulse_engine-0.1.0.dist-info/METADATA +191 -0
- codepulse_engine-0.1.0.dist-info/RECORD +56 -0
- codepulse_engine-0.1.0.dist-info/WHEEL +5 -0
- codepulse_engine-0.1.0.dist-info/entry_points.txt +2 -0
- codepulse_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
- codepulse_engine-0.1.0.dist-info/top_level.txt +1 -0
codepulse/search/bm25.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Sequence
|
|
4
|
+
|
|
5
|
+
import tantivy
|
|
6
|
+
|
|
7
|
+
from codepulse.models import Symbol
|
|
8
|
+
from codepulse.search.tokenizer import tokenize_code
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _build_schema() -> "tantivy.Schema":
|
|
12
|
+
"""
|
|
13
|
+
Phase 2.1 -- the Tantivy schema backing `BM25Index`.
|
|
14
|
+
|
|
15
|
+
Fields, and why each is shaped the way it is (verified against the
|
|
16
|
+
actually-installed `tantivy` 0.22.2 Python binding -- see
|
|
17
|
+
CODEPULSE_PROGRESS.md's Phase 2.1 section for the exact APIs checked):
|
|
18
|
+
|
|
19
|
+
- `doc_id`: the CodePulse-level identity string (e.g.
|
|
20
|
+
"{repo_id}::{file_path}::{name}@{start_line}", see
|
|
21
|
+
`codepulse.symbol_identity`), stored so it can be read back on
|
|
22
|
+
every hit. `tokenizer_name="raw"` means the whole string is one exact
|
|
23
|
+
token -- this field is never searched against, only ever stored/
|
|
24
|
+
retrieved and, for `add_document`'s direct callers, exact-matched.
|
|
25
|
+
- `repo_id`: `indexed=True` (required for `IndexWriter.delete_documents`,
|
|
26
|
+
which deletes by exact term match on an indexed field) and
|
|
27
|
+
`fast=True` (columnar storage, cheap random access) -- this is the
|
|
28
|
+
field Phase 2.1's repository-replacement semantics deletes by (see
|
|
29
|
+
`remove_repo` below). `stored=True` so it round-trips onto every hit
|
|
30
|
+
without a second lookup. May be absent on a document added with
|
|
31
|
+
`repo_id=None` (direct `BM25Index` usage outside `IndexingEngine`,
|
|
32
|
+
e.g. `tests/test_bm25_vector.py`) -- verified experimentally that an
|
|
33
|
+
absent field reads back as `None` via `Document.get_first`, matching
|
|
34
|
+
the pre-Tantivy dict-based `metadata.get("repo_id")` behavior exactly.
|
|
35
|
+
- `body`: the single combined searchable text (see `add_symbol`'s own
|
|
36
|
+
docstring for its exact construction, unchanged from the pre-Tantivy
|
|
37
|
+
implementation). Uses the built-in `tokenizer_name="whitespace"` --
|
|
38
|
+
the text is pre-tokenized through this project's own
|
|
39
|
+
`codepulse.search.tokenizer.tokenize_code` (the same code-aware
|
|
40
|
+
snake_case/camelCase splitter the old in-memory index used) and
|
|
41
|
+
joined with spaces BEFORE being handed to Tantivy, so indexing and
|
|
42
|
+
querying both go through the identical, already-tested tokenizer;
|
|
43
|
+
Tantivy's own "default" tokenizer is deliberately NOT used here, since
|
|
44
|
+
it doesn't split camelCase identifiers the way `tokenize_code` does,
|
|
45
|
+
and reusing a "raw"/"whitespace" field is the standard way to feed a
|
|
46
|
+
pre-tokenized stream into Tantivy without it re-processing the terms.
|
|
47
|
+
Not stored -- nothing reads this field back, only the parsed
|
|
48
|
+
`metadata` JSON blob (below) is used to reconstruct a result.
|
|
49
|
+
- `metadata`: a JSON field, stored, carrying the exact metadata dict
|
|
50
|
+
`add_symbol`/`add_document` were given (including its own
|
|
51
|
+
`repo_id` key) -- read back verbatim via `Document.get_first`
|
|
52
|
+
(verified experimentally that a Python dict passed to
|
|
53
|
+
`Document.add_json` round-trips as a real `dict`, not a JSON string).
|
|
54
|
+
"""
|
|
55
|
+
builder = tantivy.SchemaBuilder()
|
|
56
|
+
builder.add_text_field("doc_id", stored=True, tokenizer_name="raw")
|
|
57
|
+
builder.add_integer_field("repo_id", stored=True, indexed=True, fast=True)
|
|
58
|
+
builder.add_text_field("body", stored=False, tokenizer_name="whitespace")
|
|
59
|
+
builder.add_json_field("metadata", stored=True)
|
|
60
|
+
return builder.build()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class BM25Index:
|
|
64
|
+
"""
|
|
65
|
+
Embedded Tantivy-backed lexical index for codebase symbols (Phase 2.1).
|
|
66
|
+
|
|
67
|
+
Replaces the pre-Phase-2.1 pure-Python, in-memory BM25Okapi
|
|
68
|
+
implementation's *internals* with the embedded `tantivy` library
|
|
69
|
+
(roadmap §36 Phase 2: "swap `BM25Index` for a Tantivy-backed embedded
|
|
70
|
+
lexical index... without changing the ranking contract") while
|
|
71
|
+
preserving, unchanged, every part of the class's external contract that
|
|
72
|
+
`IndexingEngine`, `HybridRanker`, and the existing test suite depend on:
|
|
73
|
+
|
|
74
|
+
- Constructor: `BM25Index(k1=None, b=None)`, falling back to
|
|
75
|
+
`get_settings().bm25_k1`/`bm25_b` -- `.k1`/`.b` remain real,
|
|
76
|
+
readable attributes (`tests/test_config.py`) even though Tantivy's
|
|
77
|
+
own internal BM25 scorer does not expose a configurable k1/b through
|
|
78
|
+
its Python binding (verified: no such parameter exists anywhere in
|
|
79
|
+
`tantivy.Query`/`tantivy.Searcher`/`tantivy.Index`'s introspected
|
|
80
|
+
method surface) -- Tantivy's fixed internal BM25 implementation is
|
|
81
|
+
used as-is. This is a disclosed, expected candidate-generation
|
|
82
|
+
difference from the old hand-rolled formula, not a defect: the
|
|
83
|
+
roadmap's own words are "without changing the ranking contract," and
|
|
84
|
+
the *contract* is the shape/meaning of what `HybridRanker` consumes
|
|
85
|
+
(Section 5/9 of the Phase 2.1 architecture gate), not
|
|
86
|
+
bit-for-bit-identical BM25 arithmetic.
|
|
87
|
+
- `.doc_ids`: a real `list[str]` of every currently-indexed document id
|
|
88
|
+
(`tests/test_ignore_discovery.py` reads this directly).
|
|
89
|
+
- `.num_docs`: the current, live document count (post-deletion, not a
|
|
90
|
+
monotonic counter) -- `tests/test_restart_idempotency.py` and
|
|
91
|
+
`IndexingEngine.search()`'s scoped-search candidate-pool sizing both
|
|
92
|
+
depend on this being accurate at any point in time, not just after a
|
|
93
|
+
`build()` call.
|
|
94
|
+
- `add_symbol(doc_id, symbol, extra_text="", repo_id=None)`,
|
|
95
|
+
`add_document(doc_id, text, metadata=None)`, `build()`,
|
|
96
|
+
`search(query, top_k=20)` -- identical signatures and return shapes.
|
|
97
|
+
|
|
98
|
+
Two behaviors are new in Phase 2.1, both required by the Phase 2.1
|
|
99
|
+
architecture gate's approved idempotency design and *not* available in
|
|
100
|
+
the old parallel-list implementation:
|
|
101
|
+
|
|
102
|
+
- `remove_repo(repo_id)`: deletes every currently-indexed document
|
|
103
|
+
belonging to `repo_id`. `IndexingEngine.index_directory()` calls this
|
|
104
|
+
once, immediately before (re-)adding a repository's freshly-parsed
|
|
105
|
+
symbols, so repeated indexing replaces rather than accumulates a
|
|
106
|
+
repository's lexical documents (see that method's own comment for
|
|
107
|
+
the full reasoning; this was a real, pre-existing, untested gap in
|
|
108
|
+
the old `BM25Index`, not something Phase 2.1 is required to fix by
|
|
109
|
+
the roadmap's literal text, but is fixed here because Tantivy's
|
|
110
|
+
`IndexWriter.delete_documents` makes it essentially free and it
|
|
111
|
+
closes a genuine correctness gap with zero ranking-contract impact).
|
|
112
|
+
- A single `IndexWriter` is created once, in `__init__`, and reused for
|
|
113
|
+
every subsequent `add_symbol`/`add_document`/`remove_repo`/`build()`
|
|
114
|
+
call for the lifetime of this `BM25Index` instance -- verified
|
|
115
|
+
experimentally that a Tantivy `IndexWriter` is safe to reuse across
|
|
116
|
+
many add/delete/commit cycles (not a one-shot, single-use object).
|
|
117
|
+
|
|
118
|
+
Source of truth: per the Phase 2.1 architecture gate's Section 5, this
|
|
119
|
+
Tantivy index is a DERIVED, REBUILDABLE search artifact, never the
|
|
120
|
+
durable source of truth -- `SymbolModel`/`FileModel` rows remain
|
|
121
|
+
authoritative, exactly as they already are for the pre-Tantivy
|
|
122
|
+
implementation. The index lives entirely in memory
|
|
123
|
+
(`tantivy.Index(schema)` with no path argument -- verified
|
|
124
|
+
experimentally that this constructs an in-RAM index) and is NOT
|
|
125
|
+
persisted to disk directly -- there are no on-disk Tantivy index
|
|
126
|
+
segments anywhere. It does NOT survive a process restart on its own.
|
|
127
|
+
|
|
128
|
+
Backend-hardening addition (not a roadmap sprint item):
|
|
129
|
+
`IndexingEngine.hydrate_repository`/`hydrate_all` now rebuild this
|
|
130
|
+
index at engine-construction time from the durable `SymbolModel`/
|
|
131
|
+
`FileModel` rows above (re-tokenizing already-stored text, no
|
|
132
|
+
re-parsing, no embedding-model call) -- so a fresh `IndexingEngine`
|
|
133
|
+
has working keyword search immediately, without requiring a new
|
|
134
|
+
`index_directory()` call first. This is still a from-scratch,
|
|
135
|
+
in-memory REBUILD on every process start (not literal on-disk index
|
|
136
|
+
persistence, which was evaluated and deliberately not pursued --
|
|
137
|
+
see CODEPULSE_PROGRESS.md's own entry for the reasoning), just one
|
|
138
|
+
that now happens automatically at startup as well as at indexing
|
|
139
|
+
time.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
def __init__(self, k1: float | None = None, b: float | None = None) -> None:
|
|
143
|
+
from codepulse.config import get_settings
|
|
144
|
+
settings = get_settings()
|
|
145
|
+
self.k1 = k1 if k1 is not None else settings.bm25_k1
|
|
146
|
+
self.b = b if b is not None else settings.bm25_b
|
|
147
|
+
|
|
148
|
+
self._schema = _build_schema()
|
|
149
|
+
self._index = tantivy.Index(self._schema)
|
|
150
|
+
# A single writer, created once and reused for this instance's
|
|
151
|
+
# entire lifetime -- verified experimentally (see module docstring)
|
|
152
|
+
# that this is safe across many add/delete/commit cycles, avoiding
|
|
153
|
+
# the cost (and, per tantivy-py's own locking model, the risk) of
|
|
154
|
+
# constructing a fresh writer on every `index_directory()` call.
|
|
155
|
+
self._writer = self._index.writer(heap_size=50_000_000, num_threads=1)
|
|
156
|
+
# A searcher reflecting the index's state as of the most recent
|
|
157
|
+
# `build()` (or, before the first `build()`, the empty index) --
|
|
158
|
+
# refreshed at the end of every `build()`. A `Searcher` is a frozen
|
|
159
|
+
# snapshot in tantivy-py (verified experimentally: a `Searcher`
|
|
160
|
+
# fetched before a commit does NOT see documents added by that
|
|
161
|
+
# commit), so `search()` must never reuse a stale one.
|
|
162
|
+
self._searcher = self._index.searcher()
|
|
163
|
+
|
|
164
|
+
# Live bookkeeping mirroring the actual indexed document set --
|
|
165
|
+
# needed because tantivy-py's `Document`/`DocAddress` have no
|
|
166
|
+
# concept of "the CodePulse doc_id" as a first-class handle, and
|
|
167
|
+
# because `.doc_ids`/`.num_docs` (an existing, tested external
|
|
168
|
+
# contract -- see class docstring) must reflect the CURRENT
|
|
169
|
+
# (post-deletion) state at any moment, not just immediately after a
|
|
170
|
+
# `build()`.
|
|
171
|
+
self.doc_ids: list[str] = []
|
|
172
|
+
self._repo_by_doc_id: dict[str, int | None] = {}
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def num_docs(self) -> int:
|
|
176
|
+
return len(self.doc_ids)
|
|
177
|
+
|
|
178
|
+
def add_symbol(
|
|
179
|
+
self,
|
|
180
|
+
doc_id: str,
|
|
181
|
+
symbol: Symbol,
|
|
182
|
+
extra_text: str = "",
|
|
183
|
+
repo_id: int | None = None,
|
|
184
|
+
) -> None:
|
|
185
|
+
"""
|
|
186
|
+
Adds a Symbol to the lexical index.
|
|
187
|
+
|
|
188
|
+
`repo_id` (Phase 0.6) is carried through into the document's
|
|
189
|
+
metadata so repository-scoped search can filter candidates by
|
|
190
|
+
owning repository without needing a second identity system --
|
|
191
|
+
it's the same `RepositoryModel.id` already used elsewhere.
|
|
192
|
+
Optional/None for backward compatibility with any caller that
|
|
193
|
+
doesn't track a repository (e.g. tests exercising this index
|
|
194
|
+
directly, unrelated to Phase 0.6).
|
|
195
|
+
"""
|
|
196
|
+
# Combine name, docstring, signature, and body snippet -- unchanged
|
|
197
|
+
# construction from the pre-Tantivy implementation (Phase 2.1 does
|
|
198
|
+
# not redesign the search corpus).
|
|
199
|
+
text_parts = [
|
|
200
|
+
symbol.name * 3, # Boost symbol name
|
|
201
|
+
symbol.signature,
|
|
202
|
+
symbol.docstring or "",
|
|
203
|
+
extra_text,
|
|
204
|
+
symbol.body[:1000] if symbol.body else "",
|
|
205
|
+
]
|
|
206
|
+
combined_text = " ".join(text_parts)
|
|
207
|
+
self.add_document(doc_id, combined_text, metadata={
|
|
208
|
+
"symbol_name": symbol.name,
|
|
209
|
+
"file_path": symbol.file_path,
|
|
210
|
+
"type": symbol.type.value if hasattr(symbol.type, "value") else str(symbol.type),
|
|
211
|
+
"start_line": symbol.start_line,
|
|
212
|
+
"end_line": symbol.end_line,
|
|
213
|
+
"signature": symbol.signature,
|
|
214
|
+
"docstring": symbol.docstring,
|
|
215
|
+
"language": symbol.language,
|
|
216
|
+
"repo_id": repo_id,
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
def add_document(self, doc_id: str, text: str, metadata: dict[str, Any] | None = None) -> None:
|
|
220
|
+
"""Indexes a single document text with its ID and optional metadata."""
|
|
221
|
+
meta = metadata or {}
|
|
222
|
+
repo_id = meta.get("repo_id")
|
|
223
|
+
|
|
224
|
+
tokens = tokenize_code(text)
|
|
225
|
+
|
|
226
|
+
doc = tantivy.Document()
|
|
227
|
+
doc.add_text("doc_id", doc_id)
|
|
228
|
+
if repo_id is not None:
|
|
229
|
+
doc.add_integer("repo_id", repo_id)
|
|
230
|
+
doc.add_text("body", " ".join(tokens))
|
|
231
|
+
doc.add_json("metadata", meta)
|
|
232
|
+
self._writer.add_document(doc)
|
|
233
|
+
|
|
234
|
+
self.doc_ids.append(doc_id)
|
|
235
|
+
self._repo_by_doc_id[doc_id] = repo_id
|
|
236
|
+
|
|
237
|
+
def remove_repo(self, repo_id: int) -> None:
|
|
238
|
+
"""
|
|
239
|
+
Deletes every currently-indexed lexical document belonging to
|
|
240
|
+
`repo_id` (Phase 2.1 repository-replacement semantics -- see class
|
|
241
|
+
docstring and `IndexingEngine.index_directory()`'s call site).
|
|
242
|
+
Safe to call for a `repo_id` with no existing documents (verified
|
|
243
|
+
experimentally: `IndexWriter.delete_documents` on a non-matching
|
|
244
|
+
term is a harmless no-op), which covers both the first-ever index
|
|
245
|
+
of a repository and an empty repository (roadmap Phase 2.1 test
|
|
246
|
+
cases A and F).
|
|
247
|
+
"""
|
|
248
|
+
self._writer.delete_documents("repo_id", repo_id)
|
|
249
|
+
removed = {doc_id for doc_id, r in self._repo_by_doc_id.items() if r == repo_id}
|
|
250
|
+
for doc_id in removed:
|
|
251
|
+
del self._repo_by_doc_id[doc_id]
|
|
252
|
+
if removed:
|
|
253
|
+
self.doc_ids = [d for d in self.doc_ids if d not in removed]
|
|
254
|
+
|
|
255
|
+
def remove_docs(self, doc_ids: list[str]) -> None:
|
|
256
|
+
"""
|
|
257
|
+
Phase 3.1 (roadmap §37 item 3.1: git-diff change detection's
|
|
258
|
+
"(deleted)" category) -- deletes exactly the given documents by
|
|
259
|
+
their exact `doc_id` (the `doc_id` field is `tokenizer_name="raw"`,
|
|
260
|
+
so this is an exact-term match, never a partial/fuzzy one), unlike
|
|
261
|
+
`remove_repo`'s coarser whole-repository granularity. Used when
|
|
262
|
+
`IndexingEngine.index_directory()` detects a specific set of
|
|
263
|
+
deleted files via `codepulse.git_diff` and needs to remove only
|
|
264
|
+
those files' symbols from the lexical index, leaving every other
|
|
265
|
+
symbol in the same repository (and every other repository)
|
|
266
|
+
untouched. Safe to call with a `doc_id` that is not currently
|
|
267
|
+
indexed (a harmless no-op for that one id, matching
|
|
268
|
+
`remove_repo`'s existing no-op-safety precedent).
|
|
269
|
+
"""
|
|
270
|
+
to_remove = set(doc_ids)
|
|
271
|
+
if not to_remove:
|
|
272
|
+
return
|
|
273
|
+
for doc_id in to_remove:
|
|
274
|
+
self._writer.delete_documents("doc_id", doc_id)
|
|
275
|
+
for doc_id in to_remove:
|
|
276
|
+
self._repo_by_doc_id.pop(doc_id, None)
|
|
277
|
+
self.doc_ids = [d for d in self.doc_ids if d not in to_remove]
|
|
278
|
+
|
|
279
|
+
def build(self) -> None:
|
|
280
|
+
"""
|
|
281
|
+
Commits every `add_document`/`remove_repo` call made since the
|
|
282
|
+
last `build()`, and refreshes this index's searcher so `search()`
|
|
283
|
+
immediately sees the committed state -- the same two-phase
|
|
284
|
+
"accumulate, then finalize" shape the pre-Tantivy implementation's
|
|
285
|
+
`build()` (IDF/avgdl computation) had, now mapped onto Tantivy's
|
|
286
|
+
own commit/reload lifecycle (verified experimentally: a `Searcher`
|
|
287
|
+
fetched before `commit()`+`reload()` does not see the new writes;
|
|
288
|
+
one fetched after does).
|
|
289
|
+
"""
|
|
290
|
+
self._writer.commit()
|
|
291
|
+
self._index.reload()
|
|
292
|
+
self._searcher = self._index.searcher()
|
|
293
|
+
|
|
294
|
+
def search(self, query: str, top_k: int = 20) -> list[dict[str, Any]]:
|
|
295
|
+
"""
|
|
296
|
+
Searches the index with the given query.
|
|
297
|
+
Returns a sorted list of matched documents with normalized lexical
|
|
298
|
+
relevance scores in [0, 1] -- same shape as the pre-Tantivy
|
|
299
|
+
implementation: `{"doc_id", "raw_score", "normalized_score",
|
|
300
|
+
"metadata"}` per result, sorted descending, truncated to `top_k`.
|
|
301
|
+
"""
|
|
302
|
+
if self.num_docs == 0 or top_k < 1:
|
|
303
|
+
return []
|
|
304
|
+
|
|
305
|
+
query_tokens = tokenize_code(query)
|
|
306
|
+
if not query_tokens:
|
|
307
|
+
return []
|
|
308
|
+
|
|
309
|
+
subqueries = [
|
|
310
|
+
(tantivy.Occur.Should, tantivy.Query.term_query(self._schema, "body", token))
|
|
311
|
+
for token in query_tokens
|
|
312
|
+
]
|
|
313
|
+
tantivy_query = tantivy.Query.boolean_query(subqueries)
|
|
314
|
+
search_result = self._searcher.search(tantivy_query, limit=top_k)
|
|
315
|
+
|
|
316
|
+
hits = list(search_result.hits)
|
|
317
|
+
# Preserve the exact pre-Tantivy normalization semantics: divide
|
|
318
|
+
# every raw score in THIS result set by the highest raw score in
|
|
319
|
+
# that same set (not a min-max/global normalization), with the
|
|
320
|
+
# same zero-max safety fallback the old implementation used.
|
|
321
|
+
max_score = max((score for score, _ in hits), default=0.0)
|
|
322
|
+
if max_score <= 0:
|
|
323
|
+
max_score = 1.0
|
|
324
|
+
|
|
325
|
+
results: list[dict[str, Any]] = []
|
|
326
|
+
for score, doc_address in hits:
|
|
327
|
+
doc = self._searcher.doc(doc_address)
|
|
328
|
+
normalized_score = min(1.0, score / max_score) if max_score > 0 else 0.0
|
|
329
|
+
results.append({
|
|
330
|
+
"doc_id": doc.get_first("doc_id"),
|
|
331
|
+
"raw_score": float(score),
|
|
332
|
+
"normalized_score": normalized_score,
|
|
333
|
+
"metadata": doc.get_first("metadata") or {},
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
# Tantivy's own TopDocs collector already returns hits sorted
|
|
337
|
+
# descending by score, truncated to `limit` -- this sort is a
|
|
338
|
+
# cheap, defensive guarantee of that same ordering (matching the
|
|
339
|
+
# pre-Tantivy implementation's own explicit sort), not a
|
|
340
|
+
# correction of anything observed to be wrong.
|
|
341
|
+
results.sort(key=lambda r: r["raw_score"], reverse=True)
|
|
342
|
+
return results[:top_k]
|