Retrieval¶
Search orchestration, reciprocal-rank fusion, cross-encoder rerank, and citation resolution. This is the deterministic core: no LLM runs on any path in this package.
Search¶
groundkit.retrieval.search ¶
BM25 + dense + hybrid search returning citation-bearing results.
Deterministic — no LLM in this path (SPEC.md §2). Phase 1 shipped the BM25 path; Phase 3 Wave C adds the dense read path and RRF hybrid fusion behind the same surface, with the document-source join happening exactly once, on the surviving results (ADR-0006). Rerank arrives in Wave D.
Retriever ¶
Retriever(
store: MetadataStoreProtocol,
bm25: LexicalIndexProtocol,
config: RetrievalConfig | None = None,
*,
embedder: EmbeddingProtocol | None = None,
vector_store: VectorStoreProtocol | None = None,
documents_at_open: frozenset[str] | None = None,
collection_is_dense_bound: bool = False,
collection: str | None = None,
)
Search a persisted collection, resolving every hit to a citation.
Construct via :meth:open, which rebuilds the in-memory BM25 index from
the metadata store (ADR-0002) and — when a dense pair is supplied —
verifies the collection's embedding-identity manifest (ADR-0004
decision 3) and captures the open()-time document snapshot the dense
staleness filter is defined over.
A Retriever reflects the store's state at open() time only and is
never refreshed — it must be reopened after any write to the store
(re-ingest, edit, deletion) to observe that write. On the BM25 side that
is structural: the in-memory index is a rebuild, so content ingested
after open() has no representation in it at all and silently returns
zero results, while a hit against modified or deleted content fails
closed (:class:~groundkit.errors.RetrievalError, per the index
inconsistency check in :meth:search). The dense side reads a live
vector-store handle, so the same snapshot semantics are enforced by
filtering instead of by rebuild: dense hits are restricted to documents
that existed at open() time, making content ingested afterwards
silently invisible on both paths in exactly the same way. Both paths
fail closed identically on a hit whose document has no stored source —
on the dense side that includes orphaned vectors, i.e. rows whose
document a BM25-only Indexer deleted from SQLite without a vector
store to delete from, or the residue of an interrupted ingest
(KNOWN_LIMITATIONS.md). Stale content cannot be resolved through
the dense path at all: a dense-enabled indexer deletes a replaced
document's vectors in lockstep (ADR-0004 decision 6), and the fail-closed
join covers the case where it did not. See ADR-0002's Consequences
section for the full staleness discussion.
Source code in src/groundkit/retrieval/search.py
open
async
classmethod
¶
open(
store: MetadataStoreProtocol,
config: RetrievalConfig | None = None,
*,
embedder: EmbeddingProtocol | None = None,
vector_store: VectorStoreProtocol | None = None,
collection: str | None = None,
) -> Retriever
Build a retriever over store, rebuilding BM25 from persisted chunks.
With a dense pair supplied, the collection's embedding-identity
manifest is verified first — before the O(corpus) BM25 rebuild does
any work — closing the second of ADR-0004 decision 3's two
boundaries (Indexer closed the ingest boundary in Wave B). A
mismatch is :class:~groundkit.errors.IndexIdentityError, never a
re-embed and never a fallback. A collection with no manifest (never
dense-ingested) verifies trivially here — opening is legitimate, and
a caller may then search "bm25" — but a dense or hybrid
search against it is refused in :meth:search (ADR-0008), because
there are no vectors for either mode to read.
That verification is the single source of the dense-bound
verdict, which is why verify_manifest returns the manifest it
checked instead of this method reading it again afterwards. Deciding
"is this collection dense-bound" from a later read than the one
the identity was checked against is a TOCTOU hole with a silent
wrong answer at the end of it: an unbound collection passes the
identity check trivially, and if a concurrent dense ingest (another
task, or another process — the store's lock spans neither) binds it
to a different provider before the later read, that read reports
"bound" and this retriever proceeds to answer dense and
hybrid queries by matching this embedder's query vectors
against that provider's index. Mixed embedding spaces, the exact
corruption ADR-0004 exists to make unrepresentable, with no error
anywhere. One read decides both, so the two answers cannot disagree.
The residual window is deliberately biased closed: a collection
bound after this read is treated as unbound, so dense and
hybrid are refused rather than answered. That is also the
already-documented snapshot semantics — a retriever never observes
writes that land after open(), and a dense side that did not
exist at open() is no exception.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
store
|
MetadataStoreProtocol
|
The collection's metadata store. |
required |
config
|
RetrievalConfig | None
|
Retrieval settings (defaults if omitted). |
None
|
embedder
|
EmbeddingProtocol | None
|
Optional embedding provider for the dense read path
(keyword-only; both or neither with |
None
|
vector_store
|
VectorStoreProtocol | None
|
Optional dense vector store (keyword-only). |
None
|
collection
|
str | None
|
Optional collection name (keyword-only), attached
to every span this retriever's :meth: |
None
|
Returns:
| Type | Description |
|---|---|
Retriever
|
A ready-to-search :class: |
Raises:
| Type | Description |
|---|---|
ConfigurationError
|
Exactly one of |
IndexIdentityError
|
The collection is bound to a different embedding identity (ADR-0004). |
StorageError
|
The collection is manifest-bound and holds
documents, but its vector store is empty — a lost dense
side, refused here rather than silently answering every
dense and hybrid query from an empty index. See
:func: |
Source code in src/groundkit/retrieval/search.py
145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 | |
search
async
¶
Search the collection.
The default mode is "bm25" deliberately, and stays so under
ADR-0007 even though Wave E's measured eval delta favoured hybrid on
every retrieval-quality metric. The reason is abstention: "hybrid"
applies no score_threshold (ADR-0005 decision 6 — see below), so
no configuration makes it return nothing for a question the corpus
cannot answer, and a default that always answers is the wrong default
for citation-verifiable retrieval. "dense" does honour
score_threshold; what it lacks is a measured, defensible value.
score_threshold applies to the producer-scored modes (bm25,
dense) and never to hybrid: fused scores are rank-derived
quantities an absolute threshold would silently reinterpret, so
ADR-0005 decision 6 excludes them — thresholding the pre-fusion
candidate lists would reintroduce the same threshold by a side door,
so those are not thresholded either.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
query
|
str
|
Non-empty query text. |
required |
top_k
|
int | None
|
Result cap for this call; defaults to config. Must be in
|
None
|
mode
|
SearchMode
|
|
'bm25'
|
Returns:
| Name | Type | Description |
|---|---|---|
A |
SearchResponse
|
class: |
SearchResponse
|
citation (source + character offsets), with |
|
SearchResponse
|
|
|
honestly |
SearchResponse
|
|
Raises:
| Type | Description |
|---|---|
RetrievalError
|
Empty/whitespace query, out-of-range |
ConfigurationError
|
|
Wrapped in one OTel span, groundkit.retrieve.search (SPEC.md §3,
ADR-0022 decision 5), covering the whole call — validation, the
store read, and whichever mode branch runs. Only allowlisted
attributes ever reach it: collection name, retrieval mode, stage,
top_k, result count, latency, and — on any failure — the raised
exception's type name as a typed failure code. query itself
is never attached, at any point in this method or anything it
calls — SPEC.md §3 and ADR-0022 decision 3 forbid query text on a
span explicitly, and this is the one property
tests/test_span_instrumentation.py exists to pin down with a
sentinel value.
Source code in src/groundkit/retrieval/search.py
253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 | |
Fusion¶
groundkit.retrieval.fusion ¶
Reciprocal-rank fusion of lexical and dense result lists (Phase 3, ADR-0005).
Pure code: no I/O, no async, no logging, and no imports beyond stdlib and
:class:~groundkit.contracts.Chunk (imported only under TYPE_CHECKING,
exactly as index/bm25.py does). This module never imports
groundkit.config, groundkit.errors, or any store — invalid arguments
raise ValueError directly, because this is a pure-math seam, not a
pipeline stage.
Every decision below is ADR-0005's, cited here rather than re-derived:
- Rank, not score (decision 1). BM25 scores are unbounded and
corpus-dependent; cosine similarity is bounded but its distribution
depends on the embedding model. Neither is comparable to the other, so
fusing by score would need an unprincipled normalization. RRF instead
consumes only each ranking's permutation:
RRFscore(d) = Σ_{r∈R} 1 / (k + r(d)). Input scores are therefore ignored entirely — only a chunk's 1-based position within each ranking matters. rrf_kis a caller-supplied parameter; this module owns no default (decision 2). The value 60 is pinned asRetrievalConfig.rrf_k's default (config.py), and its provenance — the original RRF paper's pilot investigation, corroborated by Elasticsearch'srank_constantdefault — is recorded in ADR-0005, not here. :func:reciprocal_rank_fusiontakesrrf_kas a required keyword argument and never substitutes a value of its own, so "60" has exactly one source of truth in the repo.- Tie-break is ascending
(content_hash, chunk_index)(decision 3). Equal fused scores sort by that pair — content-derived, so it survives a re-ingest, and never dependent on dict or set iteration order. It is the same tie-break the stores themselves use (index/bm25.py,index/dense.py), extended bychunk_indexbecausecontent_hashalone is not total: duplicate content shares a hash.chunk_idis deliberately not used despite being unique — it isuuid4per ingest (contracts.py), so tied results would reorder every time the same corpus was re-indexed, breaking the across-runs determinism ADR-0005 decision 3 requires. - No clamping (decision 5). With
rrf_k > 0(enforced below) and ranks starting at 1, every summed term1 / (rrf_k + rank)is strictly positive by construction, so fused scores satisfy :class:~groundkit.contracts.RetrievalResult'sge=0.0contract without a defensivemax(0.0, ...)— a clamp here would mask a bug rather than prevent one.
This module builds (Chunk, score) pairs only, exactly as
:meth:~groundkit.index.bm25.BM25Index.search and the dense stores'
search do — never :class:~groundkit.contracts.RetrievalResult. The
document-source join that turns a ranked chunk into a citation-bearing
result belongs to retrieval/search.py (ADR-0006); fusion has no store to
join against and performs no I/O.
reciprocal_rank_fusion ¶
reciprocal_rank_fusion(
rankings: Sequence[Sequence[tuple[Chunk, float]]],
*,
rrf_k: int,
top_k: int | None = None,
) -> list[tuple[Chunk, float]]
Fuse multiple rankings of (Chunk, score) pairs by reciprocal rank.
Each input score is ignored — only the 1-based rank of a chunk within
its own ranking contributes to the fused score (ADR-0005 decision 1).
A chunk's identity across rankings is its chunk_id.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
rankings
|
Sequence[Sequence[tuple[Chunk, float]]]
|
One sequence of |
required |
rrf_k
|
int
|
RRF's rank-constant. Must be > 0. Callers pass
|
required |
top_k
|
int | None
|
Maximum number of fused results to return, applied after
sorting. |
None
|
Returns:
| Type | Description |
|---|---|
list[tuple[Chunk, float]]
|
|
list[tuple[Chunk, float]]
|
broken by ascending |
list[tuple[Chunk, float]]
|
decision 3) — content-derived, so the ordering is identical across |
list[tuple[Chunk, float]]
|
re-ingests of the same corpus. When a chunk_id appears in more than |
list[tuple[Chunk, float]]
|
one ranking, the emitted |
list[tuple[Chunk, float]]
|
(lowest-index) ranking containing it. Empty |
list[tuple[Chunk, float]]
|
rankings that are all empty, yield |
Raises:
| Type | Description |
|---|---|
ValueError
|
|
Source code in src/groundkit/retrieval/fusion.py
Rerank¶
rerank_by_logits re-scores results but must not re-derive them: each
reranked RetrievalResult carries forward the source_class and
extractor of the result it replaces, rather than reverting to the
contract's "text"/None defaults. That is what keeps citation
verification (see Citations below) correct for a snapshot or
extracted document after a rerank pass, not only before one.
groundkit.retrieval.rerank ¶
Optional cross-encoder rerank using a local, non-LLM model (Phase 3, Wave D).
A cross-encoder scores each (query, passage) pair jointly rather than
comparing two independently-computed vectors, which is why it is worth a second
pass over an already-retrieved candidate list. It is not an LLM and not a
generation step: SPEC.md §2's "deterministic core, LLM at the boundary" holds —
this runs a small local sequence-classification model, in the retrieval path, on
a fixed candidate set, and returns a permutation of what it was given.
Nothing here imports torch or sentence-transformers at module scope. The
import is deferred to :func:_import_cross_encoder, called only when a
:class:CrossEncoderReranker actually loads its model, so
import groundkit.retrieval.rerank — and therefore every pure function below,
and the whole default test suite — works in a base install. That matters twice
over: retrieval/* is a glob in the core coverage subset
(pyproject.toml), so this module is held to the core gate, and it could not
be if exercising it required a multi-gigabyte optional dependency.
Hazard 2 is closed here, structurally¶
ADR-0001 hazard 2: ARP fed raw cross-encoder logits straight into a
score >= 0.0 field and crashed, because MS MARCO-style rerankers emit
unbounded logits and routinely emit negative ones. ADR-0005 decision 4 fixes
the shape of the fix — a sigmoid, never min-max, never a clamp:
- Total. Sigmoid maps every real number into
(0, 1), so no logit, however negative, can violatege=0.0. The contract is satisfied by construction rather than by defending against the value after the fact. - Monotonic. It cannot change the ranking, which is the only property a reranker owes. A clamp would satisfy the contract while collapsing every negatively-scored document into one tie — contract-legal and corrupt, the worst combination (ADR-0005, alternatives).
- Batch-independent. Min-max would make the same document score differently depending on what it was ranked alongside, and degenerates to a zero range on a single-result batch.
The squashing is done by :func:sigmoid in this module, over raw
logits, rather than by asking sentence-transformers for pre-activated scores.
ADR-0005 decision 4 requires the activation to be set explicitly and never
inherited from a library default that could change; this goes one step further
in the same direction, so that the total-function guarantee lives in groundkit's
own code where :func:rerank_by_logits's regression test can exercise the exact
path production uses. :func:_import_cross_encoder still sets the model's
activation explicitly — to identity — so what arrives here is logits by
declaration rather than by assumption, and a library default flipping cannot
silently double-squash the scores.
CrossEncoderReranker ¶
Rerank retrieval results with a local cross-encoder.
Satisfies :class:~groundkit.retrieval.protocols.RerankerProtocol, whose
signature is held by a conformance test (ADR-0001 hazard 4) — the parameter
is query, and renaming it is the exact drift that test exists to catch.
The model is loaded lazily on first :meth:rerank and cached for the
instance's lifetime. Construction is therefore cheap and total: it never
touches the filesystem, the network, or the optional extra, so a
misconfigured install fails at the first real call with
:class:~groundkit.errors.RerankerNotConfiguredError rather than at import.
Usage::
reranker = CrossEncoderReranker()
top = await reranker.rerank("why is the sky blue", candidates, top_k=5)
Record the model identity; load nothing.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model_name
|
str
|
Cross-encoder identifier passed to sentence-transformers. |
DEFAULT_RERANK_MODEL
|
max_length
|
int | None
|
Optional token truncation length for the pair encoder.
|
None
|
Source code in src/groundkit/retrieval/rerank.py
rerank
async
¶
Return results reordered by cross-encoder relevance, capped at top_k.
An empty candidate list short-circuits before the model is loaded:
there is nothing to reorder, and loading a multi-gigabyte model to
return [] would make an empty upstream stage pay the reranker's
entire cost.
Both blocking steps run in a worker thread: the first-call model
load (:meth:_ensure_model, also serialized so concurrent cold calls
build one model rather than several) and then predict itself. Each
is synchronous and long enough to matter — the load costs a torch
import and possibly a weight download, predict costs seconds on a
real batch — and either one left on the event loop would stall every
other coroutine, including, in Phase 4, every other in-flight request,
along with the cancellations and timeouts meant to bound it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
query
|
str
|
The user query, scored jointly against each passage. |
required |
results
|
list[RetrievalResult]
|
Candidates to reorder. Not mutated. |
required |
top_k
|
int
|
Maximum number of results to return. |
5
|
Returns:
| Type | Description |
|---|---|
list[RetrievalResult]
|
A new list of at most |
list[RetrievalResult]
|
sigmoid-normalized scores. |
Raises:
| Type | Description |
|---|---|
RerankerNotConfiguredError
|
The optional extra is missing or the model could not be loaded. Never a silent passthrough. |
RetrievalError
|
|
Source code in src/groundkit/retrieval/rerank.py
458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 | |
rerank_by_logits ¶
rerank_by_logits(
results: Sequence[RetrievalResult],
logits: Sequence[float],
*,
top_k: int,
) -> list[RetrievalResult]
Reorder results by logits, sigmoid-normalized, truncated to top_k.
The pure half of reranking: no model, no I/O, no optional dependency. Every ordering and contract guarantee this module makes is made here, which is what lets the hazard-2 regression test drive the production path directly with hand-written negative logits.
Results are rebuilt through :class:~groundkit.contracts.RetrievalResult's
constructor rather than copied with model_copy(update=...). That is
deliberate and load-bearing: model_copy skips validation, so a score
that violated ge=0.0 would sail through it and the hazard-2 test would
be asserting nothing. Going through the constructor means the contract is
genuinely re-checked on every reranked result.
Ties break on (source, start_offset) — a chunk's position in its
document, which is content-derived and survives re-ingest. chunk_id
would be the obvious key and is deliberately not used, for the reason
ADR-0005 decision 3 gives for the fusion tie-break: it does not survive
re-ingestion of the same corpus, so a tie would resolve differently between
two runs over identical content.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
results
|
Sequence[RetrievalResult]
|
Candidates to reorder. Not mutated; a new list of new objects is returned. |
required |
logits
|
Sequence[float]
|
Raw model scores, positionally aligned with |
required |
top_k
|
int
|
Maximum number of results to return. Must be > 0. |
required |
Returns:
| Type | Description |
|---|---|
list[RetrievalResult]
|
A new list, best-first, of at most |
list[RetrievalResult]
|
class: |
list[RetrievalResult]
|
sigmoid-normalized scores. |
Raises:
| Type | Description |
|---|---|
RetrievalError
|
|
Source code in src/groundkit/retrieval/rerank.py
sigmoid ¶
Map an unbounded logit into [0.0, 1.0], monotonically and totally.
Computed in two branches so neither exp call can overflow: for a large
positive logit exp(-x) underflows harmlessly toward zero, and for a
large negative one the algebraically-equal exp(x) / (1 + exp(x)) form
does the same. The naive single-branch spelling raises OverflowError
somewhere past x ≈ -745, which would turn "a very confident negative
score" — an ordinary reranker output — into a crash.
Mathematically the range is the open interval (0, 1). In float64 it is
the closed one: past :data:SIGMOID_SATURATION_LOGIT the result rounds to
exactly 0.0 or 1.0. Both still satisfy
:class:~groundkit.contracts.RetrievalResult's ge=0.0 bound, so
saturation costs ordering resolution between two equally-hopeless
candidates and never contract validity.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
logit
|
float
|
Any finite float. Non-finite input is a caller bug and is rejected rather than propagated into a score. |
required |
Returns:
| Type | Description |
|---|---|
float
|
A float in |
Raises:
| Type | Description |
|---|---|
RetrievalError
|
|
Source code in src/groundkit/retrieval/rerank.py
Citations¶
groundkit.retrieval.citations ¶
Citation resolution: verify a retrieved span against its source (Phase 1).
A citation is verifiable, not vibes (SPEC.md §2): given the source file and character offsets, these helpers read the span back out of the source and compare it to what retrieval returned. Pure code, no LLM.
resolve_citation
async
¶
resolve_citation(
citation: Citation,
allowed_base_dir: Path,
*,
snapshot_dir: Path | None = None,
) -> str
Return the exact text span a citation points at, read from its source.
Dispatches on citation.source_class (ADR-0016), because "read the
source and slice" is only correct for one of the three classes. It is
correct for text precisely because a text loader makes
Document.content the file's decoded bytes, so offsets into content are
offsets into the file.
For the other two, a plain read-and-slice is wrong rather than merely incomplete:
extracted— offsets index deterministic extractor output. Reading a PDF as UTF-8 raises; reading an HTML file returns markup the offsets were never measured against. Resolving one needs the same extractor that produced it, and re-running a different version silently shifts every offset after the first difference — so this looks the citation's recorded extractor identity up in :func:~groundkit.extraction.active_extractors(ADR-0016 decision 2) and refuses when it is absent. On a match the source is re-extracted through that exact extractor and the result takes the same offset/drift checktextdoes — re-extract, slice, compare, per ADR-0016 decision 1's table. A build with neither thepdfnor thehtmlextra installed has an empty registry, so everyextractedcitation refuses there — still the correct answer, now for a reason the registry reports rather than a constant nothing could ever populate.snapshot—sourceis a URL, and it never reaches :func:~groundkit.utils.path_safety.ensure_within_base: that function validates only that its input is non-empty and null-byte-free, then hands it toos.path.realpath, which resolves"https://example.com/x"as a relative path under the current directory rather than rejecting it as not-a-path. The class — not a URL-sniffing path helper — is what keeps that boundary sharp (test_source_class.py'stest_a_url_passes_path_containment_which_is_why_class_is_checked_firstpins the hazard this dispatch exists to route around). Resolution instead reads the local copy :class:~groundkit.ingestion.url_loader.UrlLoaderwrote at ingest time (ADR-0016 decision 4) — never a re-fetch, since a re-fetch is a different observation at a different time and a mismatch against it could not distinguish a stale index from a changed server — fromsnapshot_dir/document_id(:mod:groundkit.snapshots), itself containment-checked againstsnapshot_dirbefore it is ever opened. A caller that omitssnapshot_dirgetsverdict="unresolvable"naming the parameter it needed to supply, not a crash.
Every failure here sets :attr:~groundkit.errors.RetrievalError.verdict
explicitly, at the raise site that already knows it best, so
service.tools.handle_fetch_chunk can classify the outcome by reading
an attribute instead of pattern-matching the message (ADR-0016 decision
6). drifted means the source was read and no longer matches;
unresolvable means it could not be checked at all — a distinction an
extractor-identity mismatch, and a missing snapshot_dir or snapshot
file, both fall on the unresolvable side of, because nothing was
re-read to disagree with.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
citation
|
Citation
|
The citation to resolve. |
required |
allowed_base_dir
|
Path
|
Containment root the citation's source must
resolve within (same path-safety barrier as ingestion). Unused
for a |
required |
snapshot_dir
|
Path | None
|
The per-collection snapshot containment root
(:func: |
None
|
Returns:
| Type | Description |
|---|---|
str
|
|
Raises:
| Type | Description |
|---|---|
RetrievalError
|
The source escapes |
Source code in src/groundkit/retrieval/citations.py
51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 | |
verify_citation
async
¶
verify_citation(
citation: Citation,
expected_content: str,
allowed_base_dir: Path,
*,
snapshot_dir: Path | None = None,
) -> bool
True when the cited span in the source equals expected_content.
Raises:
| Type | Description |
|---|---|
RetrievalError
|
As :func: |