Skip to content

Search

Every name on this page is importable from sci_etl_core.search.

sci_etl_core.search.query

FIELDS module-attribute

FIELDS: tuple[str, ...] = ('title', 'abstract', 'body')

The text fields of a document, in index column order; a term or phrase can be scoped to any of them.

NEAR_DISTANCE module-attribute

NEAR_DISTANCE = 10

The default :attr:Near.distance, as in FTS5.

Node module-attribute

Node = Term | Phrase | Near | And | Or | Not

Term dataclass

Term(
    text: str,
    fields: tuple[str, ...] = (),
    prefix: bool = False,
)

One tokenizer-normalized word, matched exactly or, with prefix, as a prefix.

fields limits the match to those fields; empty means every field.

Raises:

Type Description
ValueError

text is empty, or fields names an unknown field or repeats one.

text instance-attribute

text: str

fields class-attribute instance-attribute

fields: tuple[str, ...] = ()

prefix class-attribute instance-attribute

prefix: bool = False

Phrase dataclass

Phrase(
    words: tuple[str, ...], fields: tuple[str, ...] = ()
)

Tokenizer-normalized words that must appear adjacent and in this order.

fields limits the match to those fields; empty means every field.

Raises:

Type Description
ValueError

words is empty or holds an empty word, or fields names an unknown field or repeats one.

words instance-attribute

words: tuple[str, ...]

fields class-attribute instance-attribute

fields: tuple[str, ...] = ()

Near dataclass

Near(
    operands: tuple[Term | Phrase, ...],
    distance: int = NEAR_DISTANCE,
    fields: tuple[str, ...] = (),
)

Terms and phrases that must all occur in one field, close to each other, in any order.

With the occurrences chosen so that the one starting last starts at token p, every other operand must end at most distance tokens before p: NEAR(a b, 2) matches a x x b and b x a but not a x x x b. The operands carry no field scope of their own; fields limits the whole group, and empty means any one field.

Raises:

Type Description
ValueError

There are fewer than two operands, an operand has fields of its own, distance is negative, or fields names an unknown field or repeats one.

operands instance-attribute

operands: tuple[Term | Phrase, ...]

distance class-attribute instance-attribute

distance: int = NEAR_DISTANCE

fields class-attribute instance-attribute

fields: tuple[str, ...] = ()

scoped_operands

scoped_operands() -> tuple[Term | Phrase, ...]

Return the operands with the group's fields applied to each.

And dataclass

And(operands: tuple[Node, ...])

Matches when every operand matches.

Raises:

Type Description
ValueError

operands is empty.

operands instance-attribute

operands: tuple[Node, ...]

Or dataclass

Or(operands: tuple[Node, ...])

Matches when any operand matches.

Raises:

Type Description
ValueError

operands is empty.

operands instance-attribute

operands: tuple[Node, ...]

Not dataclass

Not(operand: Node)

Matches when its operand does not.

operand instance-attribute

operand: Node

QueryChip dataclass

QueryChip(
    text: str,
    fields: tuple[str, ...] = (),
    operator: str = "",
    negated: bool = False,
    prefix: bool = False,
    phrase: bool = False,
    depth: int = 0,
    near: int | None = None,
)

One word or phrase of a query, labelled for display.

text is the term, or the phrase's words joined by single spaces. fields is empty when every field is searched. operator is "AND" or "OR", the operator of the innermost group holding the chip, and is empty when the whole query is this one word or phrase. negated is true when the chip must not match, and depth counts the groups around it, so a UI can bracket a (b OR c) without re-implementing the grammar. near is the distance of the NEAR group the chip stands for, whose text is then its terms and phrases, phrases in double quotes, joined by single spaces; it is None for any other chip.

text instance-attribute

text: str

fields class-attribute instance-attribute

fields: tuple[str, ...] = ()

operator class-attribute instance-attribute

operator: str = ''

negated class-attribute instance-attribute

negated: bool = False

prefix class-attribute instance-attribute

prefix: bool = False

phrase class-attribute instance-attribute

phrase: bool = False

depth class-attribute instance-attribute

depth: int = 0

near class-attribute instance-attribute

near: int | None = None

normalize

normalize(node: Node) -> Node

Return the canonical form of node, which normalize leaves unchanged.

The rules apply bottom-up until nothing changes:

  1. Not(Not(x)) becomes x.
  2. Nested And and Or flatten, and a single-operand And or Or becomes its operand.
  3. Inside an And, two or more Not operands merge by De Morgan's law into one, placed where the first stood: And((a, Not(b), Not(c))) becomes And((a, Not(Or((b, c))))).
  4. Operand order is preserved, never sorted.

The result matches exactly the documents node matches.

describe

describe(node: Node) -> list[QueryChip]

Return the words and phrases of node as chips, in the order they were written.

node is normalized first, so NOT NOT a is described as a.

semantic_text

semantic_text(node: Node) -> str

Return the words an embedder should see for node: its meaning, not its syntax.

node is normalized first. The text of every term and phrase that is not negated is joined with single spaces, in the order written. Operators, field scopes, and every negated subtree are dropped, so quasar -dwarf becomes quasar rather than pulling results towards dwarfs.

Prefix terms are left out entirely: photometr is a matching feature, not a word an embedder understands, so photometr* dwarf gives dwarf and photometr* gives the empty string.

OR loses its meaning here. A single vector cannot represent a disjunction, so quasar OR blazar gives quasar blazar, the same text as quasar AND blazar.

sci_etl_core.search.parser

MAX_GROUP_DEPTH module-attribute

MAX_GROUP_DEPTH = 32

How deeply :func:parse_query lets parentheses nest, which bounds the parser's recursion.

parse_query

parse_query(
    text: str, *, default_fields: Sequence[str] = ()
) -> Node

Parse a Boolean query into a normalized AST.

Syntax, with NOT binding tightest, then AND, then OR:

  • galaxy is a term, folded by :class:Unicode61Tokenizer. A word the tokenizer splits, such as H-alpha, is a phrase of its parts.
  • "dwarf galaxy" is a phrase; a quoted single word is a term.
  • photometr* is a prefix term. The * must directly follow one word.
  • title:quasar and title,abstract:"dwarf galaxy" scope a term or a phrase to fields, named case-insensitively from :data:FIELDS.
  • NEAR(dwarf "dark matter" halo*, 5) matches when its terms and phrases all occur in one field within 5 tokens of each other, in any order (:class:~sci_etl_core.search.query.Near). The distance defaults to 10, as in NEAR(dwarf halo). A group holding one quoted phrase, such as NEAR("dwarf halo", 3), searches for its words near each other. The group can be scoped as a whole, as in title:NEAR(dwarf halo), but nothing inside it can, and it holds no operators such as - or OR.
  • a AND b, a && b, and a b are conjunctions; a OR b and a || b are disjunctions; NOT a and -a are negations.
  • Parentheses group, nesting at most :data:MAX_GROUP_DEPTH deep.

Operators are upper case; and is an ordinary word. A term or phrase without a scope gets default_fields, and empty means every field.

Parsing is pure: it runs nothing and touches no I/O.

Raises:

Type Description
SearchQueryError

The query is empty or malformed. position and token locate the first fault, with text[position:position + len(token)] == token. A name in default_fields that is not in :data:FIELDS raises it with position=None.

parse_ranked_query

parse_ranked_query(
    text: str, *, default_fields: Sequence[str] = ()
) -> Node

Parse a query for a ranked search, which needs a term that is not negated.

This is :func:parse_query followed by :func:~sci_etl_core.search.compile_fts5.require_rankable, with the rankability error located in text.

Raises:

Type Description
SearchQueryError

The query is malformed, as for :func:parse_query, or it cannot be ranked, such as NOT b or a OR -b. A query that cannot be ranked is located at its first NOT or -.

parse_semantic_query

parse_semantic_query(
    text: str, *, default_fields: Sequence[str] = ()
) -> tuple[Node, str]

Parse a query for a semantic search, returning the AST and the text to embed.

The text is :func:~sci_etl_core.search.query.semantic_text of the AST.

Raises:

Type Description
SearchQueryError

The query is malformed or cannot be ranked, as for :func:parse_ranked_query, or every term that is not negated is a prefix term, so there is nothing to embed. That last error is located at the first prefix term, or NEAR group of prefix terms.

sci_etl_core.search.tokenize

SURROGATE_CODE_POINTS module-attribute

SURROGATE_CODE_POINTS = range(55296, 57344)

Code points :class:Unicode61Tokenizer treats as separators; FTS5 cannot receive them, so parity is unchecked.

FTS5_MAX_TOKEN_BYTES module-attribute

FTS5_MAX_TOKEN_BYTES = 32768

The UTF-8 length at which FTS5 truncates an indexed term; :class:Unicode61Tokenizer never truncates.

Token dataclass

Token(text: str, start: int, end: int)

One word of a text, folded for matching, with where it was written.

start and end are half-open character offsets into the tokenized text, so text[start:end] is the word as written.

text instance-attribute

text: str

start instance-attribute

start: int

end instance-attribute

end: int

Tokenizer

Bases: Protocol

Splits text into the words a search index matches on.

tokens

tokens(text: str) -> list[Token]

Return the tokens of text in the order they appear.

Unicode61Tokenizer

Reproduce FTS5's unicode61 remove_diacritics 2 tokenizer in pure Python.

A token is a maximal run of letters, numbers, and private-use characters as Unicode 6.1 classifies them; every other character separates tokens, so H-alpha gives h and alpha. Each character is case-folded one to one, never with :meth:str.casefold, and loses its diacritic: Müller gives muller while Straße stays straße, exactly as FTS5 indexes them. A run whose characters all fold away, such as a lone combining accent, gives no token.

The character tables come from FTS5 itself and are checked against it for every code point. Two differences are known:

  • Surrogate code points (:data:SURROGATE_CODE_POINTS) are separators here. SQLite cannot receive them, so parity cannot be checked.
  • FTS5 truncates an indexed term to :data:FTS5_MAX_TOKEN_BYTES bytes of UTF-8. This tokenizer never truncates.

tokens

tokens(text: str) -> list[Token]

Return the tokens of text in order, with offsets into text as written.

sci_etl_core.search.evaluate

Hits module-attribute

Hits = dict[str, list[tuple[int, int]]]

TokenizedDocument dataclass

TokenizedDocument(
    title: tuple[str, ...] = (),
    abstract: tuple[str, ...] = (),
    body: tuple[str, ...] = (),
)

A document's fields as token texts, tokenized once to be matched many times.

title class-attribute instance-attribute

title: tuple[str, ...] = ()

abstract class-attribute instance-attribute

abstract: tuple[str, ...] = ()

body class-attribute instance-attribute

body: tuple[str, ...] = ()

from_document classmethod

from_document(
    document: SearchDocument,
    tokenizer: Tokenizer | None = None,
) -> TokenizedDocument

Tokenize every field of document, by default with :class:Unicode61Tokenizer.

matches

matches(
    node: Node,
    document: SearchDocument | TokenizedDocument,
    *,
    tokenizer: Tokenizer | None = None,
) -> bool

Report whether document satisfies the Boolean query node.

The semantics are those FTS5 gives the expression that :func:~sci_etl_core.search.compile_fts5.to_match_expression builds:

  • The text of a :class:Term and the words of a :class:~sci_etl_core.search.query.Phrase are tokenized again, as FTS5 tokenizes a quoted string. Text that splits into several tokens matches as a phrase, and text with no token matches nothing.
  • A phrase matches consecutive tokens within one field. With prefix, a term's last token matches every token that starts with it.
  • A leaf without fields searches every field.
  • A :class:~sci_etl_core.search.query.Near group matches as :func:trim_near describes. An operand whose text has no token is dropped from the group, and a group left with one operand matches as that operand.

Any query can be evaluated, including a pure negation such as NOT b. node is interpreted exactly as given, without :func:~sci_etl_core.search.query.normalize, so evaluation stays an independent check on normalization.

tokenizer defaults to :class:Unicode61Tokenizer. It splits the query's words, and document unless it is already a :class:TokenizedDocument, which must then come from the same tokenizer.

near_occurrences

near_occurrences(
    near: Near,
    document: TokenizedDocument,
    *,
    tokenizer: Tokenizer | None = None,
) -> list[Hits]

Return where each operand of near occurs as part of a match, one mapping per operand.

The mappings are those of :func:occurrences, trimmed by :func:trim_near to the occurrences FTS5 counts, and are all empty when the group does not match. An operand dropped for having no token gets an empty mapping.

near_hits

near_hits(
    found: list[Hits], lengths: list[int], distance: int
) -> list[Hits]

Apply NEAR to each operand's occurrences, given how many tokens each operand spans.

Operands spanning no token are dropped, as FTS5 drops them, and one left alone keeps all its occurrences. Otherwise the result is :func:trim_near.

trim_near

trim_near(
    found: list[Hits], lengths: list[int], distance: int
) -> list[Hits]

Keep the occurrences that FTS5 counts as part of a NEAR match, following its algorithm step for step.

Occurrences in different fields are never near each other. A set of occurrences, one per operand, matches when every operand ends at most distance tokens before the start of the occurrence that starts last. FTS5 walks every operand's start positions together, and each time the current positions match it keeps them and advances the operand whose next position is smallest. The occurrences kept are exactly those it walks past in a match, which is what it scores and highlights, and every mapping is empty when no set matches.

occurrences

occurrences(
    leaf: Term | Phrase,
    document: TokenizedDocument,
    *,
    tokenizer: Tokenizer | None = None,
) -> dict[str, list[tuple[int, int]]]

Return where leaf occurs in document, as [start, stop) token ranges per field.

Only the fields leaf searches are considered, with the same semantics as :func:matches, and a field without an occurrence is omitted. Overlapping occurrences are all reported: the phrase a a occurs twice in a a a.

sci_etl_core.search.compile_fts5

FILTER_LEAF module-attribute

FILTER_LEAF = "d.doc_id IN (SELECT rowid FROM documents_fts WHERE documents_fts MATCH ?)"

The SQL condition each maximal rankable subtree becomes in :func:to_filter_expression.

Its ? takes that subtree's MATCH expression, and it reads d.doc_id of the documents table.

is_rankable

is_rankable(node: Node) -> bool

Report whether node compiles to a single FTS5 MATCH expression, and so can be ranked.

FTS5 has no unary NOT; its NOT subtracts one match set from another. After :func:~sci_etl_core.search.query.normalize:

  • a :class:Term, :class:Phrase, or :class:Near is rankable;
  • an :class:Or is rankable when every operand is;
  • an :class:And is rankable when every operand that is not a :class:Not is rankable, and so is the operand of its :class:Not, if it has one;
  • a :class:Not on its own is not rankable.

Normalization leaves every And with at least one operand that is not a Not, and at most one that is.

require_rankable

require_rankable(node: Node) -> None

Raise unless node can be ranked, as :func:is_rankable defines it.

Raises:

Type Description
SearchQueryError

node is not rankable, such as NOT b or NOT b OR c. position is None, because an AST carries no offsets into the text it was parsed from; :func:~sci_etl_core.search.parser.parse_ranked_query locates the first negation when the query text is at hand.

to_match_expression

to_match_expression(node: Node) -> str

Compile the normalized node into one FTS5 MATCH expression.

Every word reaches FTS5 inside a double-quoted string literal with each " doubled, so no query text can act as an FTS5 operator. Bind the result as a ? parameter; never format it into SQL. SQLite cannot take a NUL or a surrogate inside a literal, so each becomes a space; the tokenizer treats both as separators, so what matches is unchanged.

Every AND, OR, and nested NOT is parenthesized, so FTS5 operator precedence never decides the meaning.

Raises:

Type Description
SearchQueryError

node is not rankable (:func:require_rankable).

to_filter_expression

to_filter_expression(
    node: Node,
) -> tuple[str, tuple[str, ...]]

Compile the normalized node into a SQL boolean expression, with its parameters.

Any query compiles, including a pure negation, because SQL's AND, OR, and NOT are total where FTS5's are not. Each maximal rankable subtree becomes one :data:FILTER_LEAF, whose ? takes that subtree's :func:to_match_expression; the parameters are returned in the order their placeholders appear. The expression reads d.doc_id, so it runs as the WHERE clause of a query over documents d.

The SQL text comes only from the fixed grammar. Every word reaches SQLite as a bound parameter, inside a quoted FTS5 literal.

For example, NOT b OR c compiles to ((NOT <leaf>) OR <leaf>) with parameters ('"b"', '"c"').

sci_etl_core.search.filters

SNIPPET_OPEN module-attribute

SNIPPET_OPEN = '\x02'

Marks where a highlighted span starts in a raw snippet; :func:sanitize_text keeps it out of stored text.

SNIPPET_CLOSE module-attribute

SNIPPET_CLOSE = '\x03'

Marks where a highlighted span ends in a raw snippet; :func:sanitize_text keeps it out of stored text.

SNIPPET_ELLIPSIS module-attribute

SNIPPET_ELLIPSIS = '…'

Stands for the text a snippet leaves out before or after it.

SNIPPET_TOKENS module-attribute

SNIPPET_TOKENS = 24

The most tokens a snippet holds.

SearchFilter module-attribute

SearchFilter = MetadataFilter | RangeFilter

A filter a text search store accepts beside a query.

MetadataFilter dataclass

MetadataFilter(
    key: str, values: frozenset[str], negated: bool = False
)

Keep the records tagged with any of values under key, or with negated, drop them.

A record's tags under key are the values :func:tag_rows derives from its metadata, so 2024 stored as an integer matches the value "2024". values may be any collection of strings; it is stored as a :class:frozenset.

Raises:

Type Description
TypeError

values is a single string, which would otherwise be read as a set of characters.

ValueError

values is empty, so the filter could never match.

key instance-attribute

key: str

values instance-attribute

values: frozenset[str]

negated class-attribute instance-attribute

negated: bool = False

RangeFilter dataclass

RangeFilter(
    key: str,
    low: int | str | None = None,
    high: int | str | None = None,
    negated: bool = False,
)

Keep the records with a tag under key between low and high, or with negated, drop them.

Both bounds are inclusive, and a bound left as None is open, so RangeFilter("year", low="2020") keeps 2020 and later. A record passes when any of its tags under key is in range, as a record passes a :class:MetadataFilter when any of its tags is among the values.

The type of the bounds decides how tags compare:

  • Integer bounds compare tags numerically. Only a tag written as Python writes an integer, such as 2024, 0, or -3, can be in range; a tag such as "2024-05-01", "07", or "+3" never is.
  • Text bounds compare tags as text, character by character. high is compared with as many leading characters of the tag as it has, so high="2024-06" keeps "2024-06-30T23:59:59Z". ISO 8601 dates and years of four digits order correctly this way.

Raises:

Type Description
TypeError

A bound is neither an integer nor text, such as a bool or a float, or one bound is an integer and the other text.

ValueError

Both bounds are None, a text bound is empty, an integer bound does not fit in 64 bits, or no tag could be in range because low is above high.

key instance-attribute

key: str

low class-attribute instance-attribute

low: int | str | None = None

high class-attribute instance-attribute

high: int | str | None = None

negated class-attribute instance-attribute

negated: bool = False

contains

contains(value: str) -> bool

Report whether the tag value is in range, ignoring negated.

tag_in_range

tag_in_range(
    value: str,
    low: int | str | None,
    high: int | str | None,
) -> bool

Report whether the tag value lies between low and high, as :class:RangeFilter defines it.

The bounds are those of a valid :class:RangeFilter. The SQLite store registers this function with its connection, so both stores compare tags with the same code.

validate_filters

validate_filters(
    filters: Iterable[SearchFilter],
    allowed_keys: Collection[str] | None = None,
) -> None

Raise unless filters holds at most one filter per key, each on an allowed key.

Several values of one key belong in a single filter's values, whatever the filters' negated flags, and a :class:MetadataFilter and a :class:RangeFilter on one key are two filters too. allowed_keys is None when any key is allowed.

Raises:

Type Description
ValueError

Two filters share a key, or a key is not in allowed_keys.

validate_facet_keys

validate_facet_keys(
    keys: Iterable[str], allowed_keys: Collection[str]
) -> None

Raise unless every key in keys is one of allowed_keys.

Raises:

Type Description
ValueError

A key is not in allowed_keys.

matches_filters

matches_filters(
    metadata: Mapping[str, Any],
    filters: Iterable[SearchFilter],
) -> bool

Report whether a record with metadata passes every filter in filters.

tag_rows

tag_rows(
    metadata: Mapping[str, Any], facet_keys: Iterable[str]
) -> tuple[tuple[str, str], ...]

Return the distinct (key, value) tags of metadata under facet_keys.

A string, or an integer that is not a bool, gives one tag, and a list or tuple of them gives one tag per element. Empty strings and every other type are skipped. Keys are in sorted order, and the values of each key are sorted and never repeat, so the rows can never collide with each other.

encode_metadata

encode_metadata(metadata: Mapping[str, Any]) -> str

Encode metadata as the JSON text a text search store keeps.

Non-ASCII text stays readable UTF-8. A value JSON cannot hold never fails the encoding: a date, datetime, or time becomes its ISO 8601 text, a bytes or bytearray its repr, and anything else its str. The encoding is one-way, so a datetime reads back as a string. A lone surrogate is written as a JSON escape, so the text can always be stored.

sanitize_text

sanitize_text(text: str) -> str

Replace control characters other than tab and line breaks, and lone surrogates, with spaces.

The FTS5 tokenizer already treats every replaced character as a separator, so what matches is unchanged. The replacement guarantees that the snippet markers :data:SNIPPET_OPEN and :data:SNIPPET_CLOSE never occur in stored text, and that SQLite can always receive it.

split_markers

split_markers(
    raw: str,
) -> tuple[str, tuple[tuple[int, int], ...]]

Strip snippet markers from raw, returning the plain text and the highlighted spans.

Each span is a half-open [start, end) character range of the plain text. :data:SNIPPET_OPEN opens a span and :data:SNIPPET_CLOSE closes it. The function never raises: a second opening marker inside a span and a closing marker outside one are ignored, a span still open at the end closes there, and an empty span is dropped.

sci_etl_core.search.store_base

SearchDocument dataclass

SearchDocument(
    record_id: str,
    title: str = "",
    abstract: str = "",
    body: str = "",
    metadata: dict[str, Any] = dict(),
)

One article's searchable text, as a single indexable unit.

The lexical index holds one document per record, while the vector memory holds one row per chunk. BM25 saturates on term frequency and normalizes by document length, so chunking a document would fragment its statistics and inflate short chunks.

metadata is stored as JSON and reads back as JSON: a datetime put in comes back as its ISO 8601 string. Field types are not enforced. :class:~sci_etl_core.search.filters.MetadataFilter matches exact values, and :class:~sci_etl_core.search.filters.RangeFilter ranges of integers or of text such as ISO 8601 dates.

record_id instance-attribute

record_id: str

title class-attribute instance-attribute

title: str = ''

abstract class-attribute instance-attribute

abstract: str = ''

body class-attribute instance-attribute

body: str = ''

metadata class-attribute instance-attribute

metadata: dict[str, Any] = field(default_factory=dict)

Snippet dataclass

Snippet(
    field: str,
    text: str,
    highlights: tuple[tuple[int, int], ...] = (),
)

A passage of one field, with the matched words marked.

field is one of :data:~sci_etl_core.search.query.FIELDS. text is plain text of at most :data:~sci_etl_core.search.filters.SNIPPET_TOKENS tokens, starting or ending with :data:~sci_etl_core.search.filters.SNIPPET_ELLIPSIS where the field goes on, and highlights are half-open [start, end) character offsets into text.

field instance-attribute

field: str

text instance-attribute

text: str

highlights class-attribute instance-attribute

highlights: tuple[tuple[int, int], ...] = ()

TextHit dataclass

TextHit(
    record_id: str,
    score: float,
    snippet: str = "",
    highlights: tuple[tuple[int, int], ...] = (),
    metadata: dict[str, Any] = dict(),
    title: str = "",
    snippets: tuple[Snippet, ...] = (),
)

A document returned from a Boolean query, with its lexical score.

score is higher for a better match, and is never the raw negative value SQLite's bm25() returns. Its scale depends on the corpus, so compare scores only within one result list. snippet is plain text from the field matching best, and highlights are half-open [start, end) character offsets into it covering the matched words, so a UI applies its own markup. snippets holds one :class:Snippet for every field with a highlighted match, in :data:~sci_etl_core.search.query.FIELDS order, so a UI can show a match in the title and one in the body together. title and metadata are the document's, as the store reads them back.

record_id instance-attribute

record_id: str

score instance-attribute

score: float

snippet class-attribute instance-attribute

snippet: str = ''

highlights class-attribute instance-attribute

highlights: tuple[tuple[int, int], ...] = ()

metadata class-attribute instance-attribute

metadata: dict[str, Any] = field(default_factory=dict)

title class-attribute instance-attribute

title: str = ''

snippets class-attribute instance-attribute

snippets: tuple[Snippet, ...] = ()

BM25Weights dataclass

BM25Weights(
    title: float = 10.0,
    abstract: float = 4.0,
    body: float = 1.0,
)

How much a match in each field counts towards a document's BM25 score.

Raises:

Type Description
ValueError

A weight is negative or not finite.

title class-attribute instance-attribute

title: float = 10.0

abstract class-attribute instance-attribute

abstract: float = 4.0

body class-attribute instance-attribute

body: float = 1.0

AsyncTextSearchStore

Bases: ABC

An accumulating lexical index of articles, searchable by Boolean query.

The index is per record, while the vector memory is per chunk (see :class:SearchDocument), so lexical and semantic results are fused at the record level.

Queries arrive as parsed, normalized ASTs, never as text: a store never parses, so no backend can invent its own dialect. Metadata filters apply to the keys in :attr:facet_keys only, and a filter or facet on any other key raises :class:ValueError before any I/O.

facet_keys abstractmethod property

facet_keys: frozenset[str]

The metadata keys this store tags documents by, for filters and facets.

index abstractmethod async

index(documents: Sequence[SearchDocument]) -> None

Store documents, replacing any stored document with the same record_id.

Control characters other than tab and line breaks are stored as spaces. When documents repeats a record_id, the last one is kept.

delete_record abstractmethod async

delete_record(record_id: str) -> None

Remove the document of record_id, if one is stored.

replace_record async

replace_record(document: SearchDocument) -> None

Make document the stored document of its record.

The default deletes and then indexes; a backend that can upsert atomically should override it.

search abstractmethod async

search(
    query: Node,
    limit: int = 20,
    exclude_record_id: str | None = None,
    filters: Sequence[SearchFilter] = (),
) -> list[TextHit]

Rank the documents matching query and filters by BM25, best first.

Equal scores are ordered by record_id. filters apply before limit, so a filtered search still returns up to limit hits. A limit below 1 returns no hits.

Raises:

Type Description
ValueError

A filter's key is not in :attr:facet_keys, or two filters share a key.

SearchQueryError

query is not rankable, such as a pure negation (:func:~sci_etl_core.search.compile_fts5.is_rankable).

SearchStoreError

The index cannot be read, or a filter's key has no built tags yet (see AsyncSqliteFts5Store.rebuild_tags).

filter_ids abstractmethod async

filter_ids(
    query: Node | None = None,
    filters: Sequence[SearchFilter] = (),
) -> frozenset[str]

Return every record satisfying query and filters, in no order.

Any query is accepted, including a pure negation, which is why the result carries no order. query=None matches every document.

Raises:

Type Description
ValueError

A filter's key is not in :attr:facet_keys, or two filters share a key.

SearchStoreError

The index cannot be read, or a filter's key has no built tags yet (see AsyncSqliteFts5Store.rebuild_tags).

get_documents abstractmethod async

get_documents(
    record_ids: Collection[str],
) -> dict[str, SearchDocument]

Return the stored documents for record_ids; unknown ids are omitted.

facet_counts abstractmethod async

facet_counts(
    keys: Sequence[str],
    *,
    query: Node | None = None,
    filters: Sequence[SearchFilter] = (),
) -> dict[str, tuple[tuple[str, int], ...]]

Count matching documents per value of each facet key.

For each key, documents are counted when they satisfy query and every filter whose key is not key; filters on key itself are ignored for that key's counts. Each count answers "how many results would this value give if it were selected instead". When filters has no filter on key, the exclusion is a no-op, and that key's counts are over the full match set of query and every other key's filter. Values are sorted by count descending, then value ascending. Values with a count of 0 are omitted from the returned tuple, and a key with no matching value maps to an empty tuple.

Raises:

Type Description
ValueError

A key in keys or filters is not in :attr:facet_keys, or two filters in filters share a key.

SearchStoreError

The index cannot be read, or a key in keys or filters has no built tags yet (see AsyncSqliteFts5Store.rebuild_tags).

range_counts async

range_counts(
    ranges: Sequence[RangeFilter],
    *,
    query: Node | None = None,
    filters: Sequence[SearchFilter] = (),
) -> tuple[int, ...]

Count matching documents inside each of ranges, in the order given.

Each count is of the documents satisfying query, the range, and every filter whose key is not the range's key, so a count answers "how many results would this range give if it were selected instead", as :meth:facet_counts does for exact values. Ranges may share a key and overlap, as buckets of a histogram or presets such as "last 5 years" do, and a negated range counts the documents outside it.

The default runs :meth:filter_ids once per range; a backend that can count in one read should override it.

Raises:

Type Description
ValueError

A key in ranges or filters is not in :attr:facet_keys, or two filters in filters share a key.

SearchStoreError

The index cannot be read, or a key has no built tags yet (see AsyncSqliteFts5Store.rebuild_tags).

count abstractmethod async

count() -> int

Return the number of stored documents.

aclose async

aclose() -> None

Release any held resources. No-op by default.

sci_etl_core.search.store_memory

BM25_K1 module-attribute

BM25_K1 = 1.2

BM25_B module-attribute

BM25_B = 0.75

BM25_MIN_IDF module-attribute

BM25_MIN_IDF = 1e-06

InMemoryTextSearchStore

InMemoryTextSearchStore(
    *,
    facet_keys: Iterable[str] = (),
    weights: BM25Weights | None = None,
    tokenizer: Tokenizer | None = None,
)

Bases: AsyncTextSearchStore

Non-persistent lexical index backed by a dict, ranked as SQLite FTS5 ranks.

Documents are tokenized once, when indexed, by tokenizer (by default :class:~sci_etl_core.search.tokenize.Unicode61Tokenizer, which reproduces FTS5's own tokenizer). Matching follows :func:~sci_etl_core.search.evaluate.matches, so with the default tokenizer the store matches, filters, and counts exactly the records :class:~sci_etl_core.search.store_sqlite_fts5.AsyncSqliteFts5Store does, which makes it both an offline test double and a check on that store.

Scores follow FTS5's bm25() formula: k1 of 1.2, b of 0.75, an IDF floored at 1e-6, and the field weights. A word counts towards the score, and is highlighted, only where the part of the query holding it matches the document: an OR alternative or an AND group that does not match, and anything negated, adds nothing. FTS5 applies the same rule in most cases but not all: whether it counts a word inside a part of the query that fails to match depends on the state of its internal iterators, so for such queries the two stores can score, and so order, the same records differently. Known divergence: FTS5 may count a term inside a failing OR branch, so raw scores can differ on mixed-negation queries; matched record sets are identical. The property-based parity test compares sets, not scores.

The snippet is taken from the field with the most counted matches, with ties going to title, then abstract, then body, and snippets holds one for every field with a counted match.

Tags are derived from metadata on every write, so a filter or facet on any of facet_keys works at once: the store never needs a rebuild_tags and never raises :class:~sci_etl_core.exceptions.SearchStoreError.

facet_keys property

facet_keys: frozenset[str]

index async

index(documents: Sequence[SearchDocument]) -> None

delete_record async

delete_record(record_id: str) -> None

search async

search(
    query: Node,
    limit: int = 20,
    exclude_record_id: str | None = None,
    filters: Sequence[SearchFilter] = (),
) -> list[TextHit]

filter_ids async

filter_ids(
    query: Node | None = None,
    filters: Sequence[SearchFilter] = (),
) -> frozenset[str]

get_documents async

get_documents(
    record_ids: Collection[str],
) -> dict[str, SearchDocument]

facet_counts async

facet_counts(
    keys: Sequence[str],
    *,
    query: Node | None = None,
    filters: Sequence[SearchFilter] = (),
) -> dict[str, tuple[tuple[str, int], ...]]

count async

count() -> int

sci_etl_core.search.store_sqlite_fts5

T module-attribute

T = TypeVar('T')

AsyncSqliteFts5Store

AsyncSqliteFts5Store(
    path: str | Path,
    *,
    facet_keys: Iterable[str] = (),
    weights: BM25Weights | None = None,
    now: Callable[[], datetime] | None = None,
)

Bases: AsyncTextSearchStore

Durable lexical index on SQLite FTS5, keeping each article's text once.

Backed by the standard-library sqlite3 driver run off the event loop, so it carries no third-party dependency. The text lives in a documents table and FTS5 holds only the inverted index, kept consistent by triggers. Every write is one transaction, so re-indexing a record replaces its text, metadata, and tags atomically. The single connection is used by one worker thread at a time, even when an awaiting task is cancelled, and every SQLite failure, including a file that is not a database, surfaces as :class:~sci_etl_core.exceptions.SearchStoreError.

Ranking is FTS5's bm25() with the field weights, reported as a score where higher is better. snippet comes from the one field FTS5 chooses, and snippets holds FTS5's snippet of every field it highlights a match in. On the queries where FTS5 counts a word inside a part of the query that fails to match, as :class:~sci_etl_core.search.store_memory.InMemoryTextSearchStore describes, the two stores can highlight different words and so list different fields. Each document's indexed_at is the UTC time from now, as an ISO 8601 string with an offset.

facet_keys names the metadata keys tagged for filters and facets. It is fixed for the life of the instance and recorded in the file. To change it, construct a store with the new keys and call :meth:rebuild_tags; until then a filter or facet on a key whose tags are not built raises :class:~sci_etl_core.exceptions.SearchStoreError.

The file is opened, and created or migrated to the current schema, on the first operation rather than at construction. A file that is not a database, or whose schema is newer than this library supports, raises :class:~sci_etl_core.exceptions.SearchStoreError then.

:meth:aclose is not terminal: a later call reopens the connection. Give each instance exactly one owner, which awaits :meth:aclose, and use it from one event loop. For a one-shot script, list the store in the pipeline's closeables.

Raises:

Type Description
SearchStoreError

This interpreter's SQLite was built without FTS5.

facet_keys property

facet_keys: frozenset[str]

index async

index(documents: Sequence[SearchDocument]) -> None

delete_record async

delete_record(record_id: str) -> None

replace_record async

replace_record(document: SearchDocument) -> None

Replace the record's text, metadata, and tags in one transaction.

search async

search(
    query: Node,
    limit: int = 20,
    exclude_record_id: str | None = None,
    filters: Sequence[SearchFilter] = (),
) -> list[TextHit]

filter_ids async

filter_ids(
    query: Node | None = None,
    filters: Sequence[SearchFilter] = (),
) -> frozenset[str]

get_documents async

get_documents(
    record_ids: Collection[str],
) -> dict[str, SearchDocument]

facet_counts async

facet_counts(
    keys: Sequence[str],
    *,
    query: Node | None = None,
    filters: Sequence[SearchFilter] = (),
) -> dict[str, tuple[tuple[str, int], ...]]

range_counts async

range_counts(
    ranges: Sequence[RangeFilter],
    *,
    query: Node | None = None,
    filters: Sequence[SearchFilter] = (),
) -> tuple[int, ...]

Count matching documents inside each of ranges in one consistent read.

The counts are those :meth:AsyncTextSearchStore.range_counts describes.

count async

count() -> int

optimize async

optimize() -> None

Merge the FTS5 index's segments, which speeds up later queries. Reads no text.

rebuild_index async

rebuild_index() -> None

Re-tokenize every stored document into a fresh FTS5 index.

The text already stored in the file is used; nothing is fetched again. This repairs an index that :meth:integrity_check reports inconsistent.

rebuild_tags async

rebuild_tags() -> None

Re-derive every document's tags from its stored metadata, for this store's facet keys.

One transaction replaces every tag and records :attr:facet_keys as built, so filters and facets on those keys work afterwards, and tags of keys no longer in :attr:facet_keys are gone. No text is read.

Raises:

Type Description
SearchStoreError

The tags cannot be written, or a stored document's metadata is not a JSON object.

integrity_check async

integrity_check() -> bool

Report whether the FTS5 index agrees with the stored documents.

False means the index is inconsistent, for example after the documents table was written without its triggers; :meth:rebuild_index repairs it.

Raises:

Type Description
SearchStoreError

The check could not run.

aclose async

aclose() -> None

Close the connection; a later call transparently reopens it.

fts5_available

fts5_available(
    connect: Callable[[], Connection] | None = None,
) -> bool

Report whether this interpreter's SQLite was built with FTS5.

connect opens the connection to probe, by default an in-memory database opened through :func:sqlite3.connect as it is at call time. A probe that cannot connect, or cannot create an FTS5 table, reports False.

sci_etl_core.search.snippets

snippet_window

snippet_window(
    text: str,
    tokens: Sequence[Token],
    ranges: Sequence[tuple[int, int]],
) -> tuple[str, tuple[tuple[int, int], ...]]

Cut text to a window of at most :data:SNIPPET_TOKENS tokens around its first highlighted range.

tokens are the tokens of text, and ranges are [start, stop) token ranges to highlight, in any order and possibly overlapping. The window starts at the first range, or earlier when that range is near the end, and :data:SNIPPET_ELLIPSIS marks text left out before or after it. Returns the window's text and its highlights as [start, end) character offsets into that text, with overlapping ranges merged into one span.

passage_snippet

passage_snippet(
    query: Node,
    text: str,
    field: str = "body",
    *,
    tokenizer: Tokenizer | None = None,
) -> Snippet

Return a snippet of text, read as field, highlighting every word of query it contains.

Unlike a store's snippet, which highlights only the parts of the query a document satisfies, every term and phrase that is not negated is highlighted wherever it occurs, whether or not text matches the whole query. That suits text found another way, such as the passage a semantic search ranked best. A term or phrase scoped to other fields is not highlighted, and the terms of a NEAR group are highlighted wherever they occur. Without a highlight, the snippet is the start of text.

Raises:

Type Description
ValueError

field is not one of :data:~sci_etl_core.search.query.FIELDS.

sci_etl_core.search.index_async

AsyncSearchIndexer

AsyncSearchIndexer(store: AsyncTextSearchStore)

Index an article's title, abstract, and full text for Boolean search.

The lexical counterpart of :class:~sci_etl_core.embeddings.ingest_async.AsyncChunkIngestor: the same ingest method and the same replace-not-append semantics, with one document per record instead of one row per chunk. It satisfies :class:~sci_etl_core.ingest_protocol.MemoryIngestor, so a search-only deployment passes it straight to the pipeline's memory_ingestor.

The indexer never swallows a failure and never parses a query; whether a store fault is fatal is decided by its caller. It borrows store and never closes it.

ingest async

ingest(record: RawRecord, text: str) -> int

Replace the record's indexed document with its title, abstract, and text.

A record whose title, abstract, and text are all blank (empty or whitespace only) is removed from the index, as AsyncChunkIngestor clears a record whose text yields no passages. A record with any one of them non-blank is stored, with the blank fields stored as empty strings. record.metadata is copied into the document.

Returns:

Type Description
int

1 when a document was stored, 0 when the record was cleared.

Raises:

Type Description
SearchStoreError

The index could not be written.

sci_etl_core.search.backfill_async

BackfillReport dataclass

BackfillReport(
    indexed: int = 0,
    skipped_existing: int = 0,
    skipped_empty: int = 0,
)

What :func:backfill_text_index did with each record of the vector memory.

indexed records were written to the text index. skipped_existing were already in it and left alone, and skipped_empty had no document to write, such as a record whose passages and title are all blank.

indexed class-attribute instance-attribute

indexed: int = 0

skipped_existing class-attribute instance-attribute

skipped_existing: int = 0

skipped_empty class-attribute instance-attribute

skipped_empty: int = 0

merge_passages

merge_passages(
    passages: Sequence[str], overlap_words: int
) -> str

Join overlapping passages back into one text, keeping each overlapping word once.

Passages are split into words at whitespace, as :class:~sci_etl_core.embeddings.chunking.SlidingWindowChunker splits text, and the result joins the words with single spaces. Each passage after the first drops its first overlap_words words when they repeat the last words so far and it has more words than that, so the text of a sliding-window chunker comes back with every word counted once. Any other passage is kept whole, so passages from another chunker lose nothing.

Raises:

Type Description
ValueError

overlap_words is negative.

stored_record_document

stored_record_document(
    record: StoredRecord, body: str
) -> SearchDocument | None

Build the text-index document for a record read back from the vector memory.

The title is the title of the record's chunk metadata, the body is body, and the abstract is empty, because the vector memory does not hold it. The rest of the chunk metadata, such as source_url, becomes the document's metadata; the record's original metadata, such as categories or year, is not in the vector memory. A record with a blank title and a blank body gives None.

backfill_text_index async

backfill_text_index(
    vector_store: AsyncEmbeddingStore,
    text_store: AsyncTextSearchStore,
    *,
    overlap_words: int,
    replace_existing: bool = False,
    build_document: Callable[
        [StoredRecord, str], SearchDocument | None
    ] = stored_record_document,
    batch_size: int = 100,
) -> BackfillReport

Index the text already in the vector memory, so a deployment need not fetch full texts again.

Every record of vector_store is read with :meth:~sci_etl_core.embeddings.store_base.AsyncEmbeddingStore.iter_records, its passages are joined by :func:merge_passages with overlap_words, the overlap of the chunker that wrote them (for example SlidingWindowChunker().overlap_words), and build_document turns the record and the joined text into the document to index. Joining without removing the overlap would count the words at every window boundary twice, and BM25 would over-weigh them.

The default :func:stored_record_document has no abstract and none of the record's original metadata, so filters and facets on keys such as year do not match a backfilled document. Pass a build_document that adds them from a source of your own, or return None to leave a record out.

Records already in text_store are left alone unless replace_existing is true, because a document the pipeline indexed holds the abstract and metadata a backfilled one lacks. Documents are written batch_size at a time. Neither store is closed.

Raises:

Type Description
ValueError

overlap_words is negative or batch_size is less than 1, raised before any I/O.

NotImplementedError

vector_store cannot enumerate its records.

EmbeddingStoreError

The vector memory cannot be read.

SearchStoreError

The text index cannot be read or written.

sci_etl_core.search.fusion

ScoredList module-attribute

ScoredList = Sequence[tuple[str, float]]

FusedHit dataclass

FusedHit(
    record_id: str,
    score: float,
    lexical_rank: int | None = None,
    semantic_rank: int | None = None,
    snippet: str = "",
    highlights: tuple[tuple[int, int], ...] = (),
    metadata: dict[str, Any] = dict(),
    title: str = "",
    snippets: tuple[Snippet, ...] = (),
)

One record of a fused result list, with where each retrieval leg ranked it.

lexical_rank and semantic_rank are 1-based, and None when that leg did not return the record. snippet, highlights, and snippets are the lexical hit's when the lexical leg returned the record. A record found only by the semantic leg gets a snippet of the passage that ranked it, with the query's words highlighted where they occur in it, as its only entry in snippets, whose field is "body". score comes from the fusion strategy and is comparable only within one result list; render the rank, never the score as a percentage.

record_id instance-attribute

record_id: str

score instance-attribute

score: float

lexical_rank class-attribute instance-attribute

lexical_rank: int | None = None

semantic_rank class-attribute instance-attribute

semantic_rank: int | None = None

snippet class-attribute instance-attribute

snippet: str = ''

highlights class-attribute instance-attribute

highlights: tuple[tuple[int, int], ...] = ()

metadata class-attribute instance-attribute

metadata: dict[str, Any] = field(default_factory=dict)

title class-attribute instance-attribute

title: str = ''

snippets class-attribute instance-attribute

snippets: tuple[Snippet, ...] = ()

FusionParams dataclass

FusionParams(
    k: int = 60, weights: tuple[float, ...] | None = None
)

How ranked lists are fused.

k damps reciprocal rank fusion: a larger k flattens the difference between neighbouring ranks. weights holds one weight per fused list, in list order, and None weighs every list equally. A list of weights is stored as a tuple.

Raises:

Type Description
ValueError

k is less than 1, or a weight is negative or not finite.

k class-attribute instance-attribute

k: int = 60

weights class-attribute instance-attribute

weights: tuple[float, ...] | None = None

FusionStrategy

Bases: Protocol

Fuses ranked lists of (record_id, score) pairs into one ranking.

The result holds each record of the input lists once, as a (record_id, fused_score) pair, best first.

reciprocal_rank_fusion

reciprocal_rank_fusion(
    ranked_lists: Sequence[ScoredList],
    params: FusionParams | None = None,
) -> list[tuple[str, float]]

Fuse ranked lists by 1 / (k + rank), the rank-only fusion of Cormack et al.

Only the order of each list is read; its scores are ignored. That makes the fusion scale-free. BM25 scores are unbounded and depend on the corpus, while cosine similarity lies in [-1, 1], so any weighted sum of the two would make the effective blend drift as the corpus grows; RRF needs no calibration.

A record's rank in a list is its 1-based position among the list's distinct records, so a repeated record counts once, at its first position. Its fused score is the sum over lists of weight / (k + rank), rounded exactly, so the result does not depend on the order of the lists. The result is best first, and equal fused scores are ordered by record_id.

Raises:

Type Description
ValueError

params.weights does not hold one weight per list.

normalized_score_fusion

normalized_score_fusion(
    ranked_lists: Sequence[ScoredList],
    params: FusionParams | None = None,
) -> list[tuple[str, float]]

Fuse ranked lists by min-max normalizing each list's scores, then adding them with weights.

Unlike :func:reciprocal_rank_fusion, score mass matters: a record far ahead of the next one keeps that lead. The price is calibration. Raw BM25 scores depend on the corpus, so how much the lexical list counts drifts as the corpus changes. params.k is not used.

Within a list, the best score becomes 1 and the worst 0, and a list whose scores are all equal gives every record 1. A repeated record keeps the score of its first occurrence. The result is best first, and equal fused scores are ordered by record_id.

Raises:

Type Description
ValueError

params.weights does not hold one weight per list, or a score is not finite.

sci_etl_core.search.hybrid_async

SearchMode module-attribute

SearchMode = Literal['lexical', 'semantic', 'hybrid']

Which retrieval legs :meth:AsyncHybridSearcher.search runs.

SEARCH_MODES module-attribute

SEARCH_MODES: tuple[str, ...] = (
    "lexical",
    "semantic",
    "hybrid",
)

Every :data:SearchMode, for checking a mode that arrives as plain text.

HybridParams dataclass

HybridParams(
    candidate_pool: int = 100, chunk_pool_factor: int = 5
)

How many candidates each retrieval leg fetches before fusion.

Each leg fetches candidate_pool records, and never fewer than the top_k asked for, so a record ranked #40 lexically and #3 semantically can still reach the top 20.

The semantic leg asks the vector memory for candidate_pool × chunk_pool_factor chunks and keeps each record's best chunk. It yields candidate_pool distinct records whenever those chunks span at least that many records, that is, when records contribute on average no more than chunk_pool_factor chunks to that prefix of the chunk ranking. When a few long records dominate the top chunks, fewer records are returned. The searcher does not retry, because a retry would call the embedder again for the same text. Raise the factor for corpora of long documents.

Raises:

Type Description
ValueError

candidate_pool or chunk_pool_factor is less than 1.

candidate_pool class-attribute instance-attribute

candidate_pool: int = 100

chunk_pool_factor class-attribute instance-attribute

chunk_pool_factor: int = 5

SearchOutcome dataclass

SearchOutcome(
    hits: list[FusedHit] = list(),
    degraded: tuple[str, ...] = (),
    skipped: tuple[str, ...] = (),
)

The fused hits of one search, and which retrieval legs did not contribute.

degraded names the legs that were attempted and failed, such as "semantic" when the embedding service was unreachable. skipped names the legs that had nothing to run, such as a semantic leg without a finder or for a query made only of prefix terms. A UI words the two differently.

hits class-attribute instance-attribute

hits: list[FusedHit] = field(default_factory=list)

degraded class-attribute instance-attribute

degraded: tuple[str, ...] = ()

skipped class-attribute instance-attribute

skipped: tuple[str, ...] = ()

AsyncHybridSearcher

AsyncHybridSearcher(
    text_store: AsyncTextSearchStore,
    finder: AsyncSimilarArticleFinder | None = None,
    *,
    strategy: FusionStrategy = reciprocal_rank_fusion,
    params: HybridParams | None = None,
    fusion: FusionParams | None = None,
    logger: Callable[[str], None] | None = None,
)

Search the corpus by Boolean query, by meaning, or by both at once.

mode="lexical" ranks documents by BM25 in the text index. mode="semantic" ranks whole articles by their best-matching chunk in the vector memory. mode="hybrid" runs both at once and fuses the two rankings with strategy, by default reciprocal rank fusion.

The query is parsed once, before any I/O. The semantic leg embeds :func:~sci_etl_core.search.query.semantic_text of the query, so operators, field scopes, negated terms, and prefix terms never reach the embedder. Metadata filters apply to both legs: the lexical leg filters inside the index, and the semantic leg's candidates are narrowed to the records passing the filters before fusion, so a filtered-out record never takes a slot.

A hybrid search whose semantic leg raises :class:~sci_etl_core.exceptions.EmbeddingError falls back to the lexical results, logs the failure, and reports it in :attr:SearchOutcome.degraded. A lexical failure is never swallowed, because it means the local index is broken.

The searcher never closes; the caller that constructed the stores closes them.

Configure the searcher; finder may be None to search the text index alone.

Without a finder, a hybrid search runs the lexical leg alone and reports the semantic leg in :attr:SearchOutcome.skipped, not in degraded, and a semantic search raises :class:~sci_etl_core.exceptions.SearchQueryError.

fusion.weights, when given, weighs the lexical and the semantic list, in that order. logger receives the line logged when a hybrid search falls back to lexical results.

Raises:

Type Description
ValueError

fusion.weights does not hold exactly two weights.

search async

search(
    query: str,
    top_k: int = 20,
    *,
    mode: SearchMode = "hybrid",
    filters: Sequence[SearchFilter] = (),
) -> SearchOutcome

Return the top_k best records for query, fused across the legs mode runs.

Every error below except the last two is raised before any I/O. A top_k below 1 returns no hits.

A hit the lexical leg found carries its snippets and highlights. A hit only the semantic leg found takes its title and metadata from the text index, or from its best chunk's metadata when the index does not hold the record, and its snippet from that chunk, with the query's words highlighted where they occur in it (:func:~sci_etl_core.search.snippets.passage_snippet).

Raises:

Type Description
ValueError

mode is unknown, or filters holds two filters on one key or a key outside the text store's facet keys.

SearchQueryError

The query is malformed or cannot be ranked, such as a pure negation; text_store.filter_ids answers those. In semantic mode, also when there is no finder, or when the query has no whole word to embed.

EmbeddingError

The semantic leg failed in mode="semantic".

SearchStoreError

The text index cannot be read.

sci_etl_core.search.graph

NeighbourLists module-attribute

NeighbourLists = dict[str, list[tuple[str, float]]]

GraphNode dataclass

GraphNode(
    record_id: str,
    title: str = "",
    degree: int = 0,
    community: int = 0,
    metadata: dict[str, Any] = dict(),
)

A record in a discovery graph.

degree counts the distinct records the node shares an edge with. community is the canonical id :func:label_communities assigned. title and metadata come from the text index, and are empty for a record the index does not hold.

record_id instance-attribute

record_id: str

title class-attribute instance-attribute

title: str = ''

degree class-attribute instance-attribute

degree: int = 0

community class-attribute instance-attribute

community: int = 0

metadata class-attribute instance-attribute

metadata: dict[str, Any] = field(default_factory=dict)

GraphEdge dataclass

GraphEdge(
    source: str, target: str, weight: float, kind: str
)

An undirected weighted edge, with source sorting before target.

kind names the edge source that produced it, such as "semantic", "metadata", or "citation".

source instance-attribute

source: str

target instance-attribute

target: str

weight instance-attribute

weight: float

kind instance-attribute

kind: str

DiscoveryGraph dataclass

DiscoveryGraph(
    nodes: tuple[GraphNode, ...],
    edges: tuple[GraphEdge, ...],
    seed_record_id: str | None,
    communities_converged: bool,
)

The neighbourhood of a seed record, as topology only.

It holds no coordinates, colours, or layout: layout is a rendering concern, so a UI runs force-directed layout over this data. communities_converged is False when label propagation stopped at its pass limit, so a UI can say the communities are approximate.

nodes instance-attribute

nodes: tuple[GraphNode, ...]

edges instance-attribute

edges: tuple[GraphEdge, ...]

seed_record_id instance-attribute

seed_record_id: str | None

communities_converged instance-attribute

communities_converged: bool

GraphParams dataclass

GraphParams(
    depth: int = 2,
    fanout: int = 8,
    min_weight: float = 0.35,
    max_nodes: int = 200,
    mutual_only: bool = True,
    max_iterations: int = 20,
)

How far and how densely a discovery graph grows around its seed.

The graph grows depth breadth-first levels. Each record expands to the up to fanout neighbours per edge source whose weight is at least min_weight. max_nodes is a hard cap, checked before each level is expanded. With mutual_only, an edge is kept only when each record is among the other's fanout nearest, which keeps a hub record from connecting to everything. max_iterations bounds label propagation.

Raises:

Type Description
ValueError

depth is negative, fanout, max_nodes, or max_iterations is less than 1, or min_weight is not finite.

depth class-attribute instance-attribute

depth: int = 2

fanout class-attribute instance-attribute

fanout: int = 8

min_weight class-attribute instance-attribute

min_weight: float = 0.35

max_nodes class-attribute instance-attribute

max_nodes: int = 200

mutual_only class-attribute instance-attribute

mutual_only: bool = True

max_iterations class-attribute instance-attribute

max_iterations: int = 20

select_edges

select_edges(
    neighbour_lists: Sequence[
        tuple[
            str, Mapping[str, Sequence[tuple[str, float]]]
        ]
    ],
    nodes: Collection[str],
    *,
    min_weight: float,
    mutual_only: bool,
) -> list[GraphEdge]

Turn each edge source's neighbour lists into undirected edges between nodes.

neighbour_lists pairs each source's edge kind with its lists, which map a record to its nearest records, already cut to the fanout. A record gets an edge of that kind to a neighbour in its list when both are in nodes, they differ, and the weight is at least min_weight. With mutual_only, the record must also be in the neighbour's list. Edges of one kind between one pair merge, keeping the heaviest weight. Edges are sorted by source, target, and kind.

label_communities

label_communities(
    nodes: Sequence[str],
    edges: Sequence[GraphEdge],
    *,
    max_iterations: int = 20,
) -> tuple[dict[str, int], bool]

Assign each node a community by label propagation.

Returns the communities at convergence and True, or, if a pass limit of max_iterations is reached first, the communities after the last pass and False. The result does not depend on the order of nodes or edges:

  1. A pair joined by several edges gets the heaviest weight, in both directions. Self-loops and edges touching a record outside nodes are ignored.
  2. Each node starts with its index in sorted record_id order as its label.
  3. A pass visits nodes in sorted order and updates labels in place. A node sums edge weights per neighbouring label, visiting neighbours in sorted order, and switches only when the heaviest label's total is strictly greater than its current label's; among labels tied for heaviest, it takes the lowest.
  4. A pass that changes no label ends the run as converged. max_iterations counts every pass, including that last one.
  5. Communities are numbered 0, 1, 2, … in order of their smallest member.

Raises:

Type Description
ValueError

max_iterations is less than 1.

filter_graph

filter_graph(
    graph: DiscoveryGraph,
    *,
    matched_ids: Collection[str] | None = None,
    filters: Sequence[SearchFilter] = (),
) -> DiscoveryGraph

Keep the nodes whose record is in matched_ids and whose metadata passes filters.

The seed is always kept. Edges losing an endpoint are dropped, and degrees count the remaining edges; communities are kept as they were, so a UI's colours stay stable while filtering. matched_ids usually comes from the text store's filter_ids, which also answers a pure negation, and filters are applied in memory with the same semantics as the stores, so a facet toggle needs no I/O.

Raises:

Type Description
ValueError

Two filters share a key.

build_discovery_graph async

build_discovery_graph(
    seed_record_id: str,
    sources: Sequence[AsyncEdgeSource],
    text_store: AsyncTextSearchStore,
    *,
    params: GraphParams | None = None,
) -> DiscoveryGraph

Grow the discovery graph around seed_record_id.

Each breadth-first level issues one batched neighbours call per source, with the sources running concurrently. A record's neighbour lists are fetched at most once per build, and reused for the mutual check. When sources fail, every source is awaited first, and then the first failure in source order is raised. Each source's lists are cleaned before use: a record never neighbours itself, a repeated neighbour keeps its heaviest weight, weights that are not finite are dropped, and each list is cut to the fanout.

Edges are chosen by :func:select_edges, and only records connected to the seed through them are kept; the seed always is. Communities come from :func:label_communities, and titles and metadata from one get_documents call on text_store.

A neighbour query costs what the source costs. With an exact-scan vector store, a build issues up to one query per node, each scanning every stored chunk, so keep max_nodes small for large memories or use an approximate nearest-neighbour store behind the same interface.

Raises:

Type Description
ValueError

sources is empty.

sci_etl_core.search.edges

AsyncEdgeSource

Bases: ABC

Finds each record's nearest records by one notion of relatedness, for a discovery graph.

A source borrows the stores it reads and never closes them.

kind abstractmethod property

kind: str

The kind of the edges this source produces, such as "semantic".

neighbours abstractmethod async

neighbours(
    record_ids: Sequence[str], limit: int
) -> dict[str, list[tuple[str, float]]]

Return up to limit weighted neighbours for each record, best first.

A neighbour is a (record_id, weight) pair, and a higher weight means more related. The ids arrive as one batch, one breadth-first level of a graph build, so a source can share work across them. Every requested record is a key of the result, mapping to an empty list when it has no neighbour.

MetadataEdgeSource

MetadataEdgeSource(
    text_store: AsyncTextSearchStore,
    keys: Sequence[str] = ("categories", "authors"),
)

Bases: AsyncEdgeSource

Relates records that share tag values, such as an arXiv category or an author.

The weight is the Jaccard index of two records' tags under keys: the (key, value) tags they share divided by all their distinct tags. It needs no embeddings, only tags built for keys in the text store (see :func:~sci_etl_core.search.filters.tag_rows).

A batch costs one get_documents call for its records, one filter_ids call per distinct (key, values) among them, and one get_documents call for the candidates, so a tag shared by much of the corpus makes the candidate set large.

Raises:

Type Description
ValueError

keys is empty, or a key is not one of the text store's facet keys.

kind property

kind: str

neighbours async

neighbours(
    record_ids: Sequence[str], limit: int
) -> dict[str, list[tuple[str, float]]]

EmbeddingEdgeSource

EmbeddingEdgeSource(
    embedder: AsyncEmbedder,
    store: AsyncEmbeddingStore,
    text_store: AsyncTextSearchStore,
    *,
    chunk_pool_factor: int = 5,
)

Bases: AsyncEdgeSource

Relates records whose text means similar things, by cosine similarity in the vector memory.

For each batch, the records' title and abstract are read from text_store and embedded in one embed call. Each record then runs one vector-memory query for limit × chunk_pool_factor chunks, excluding its own chunks, and keeps each other record's best chunk, as :meth:~sci_etl_core.embeddings.finder_async.AsyncSimilarArticleFinder.find_similar_articles does. chunk_pool_factor has the meaning of :attr:~sci_etl_core.search.hybrid_async.HybridParams.chunk_pool_factor. A record absent from the text index, or whose title and abstract are both blank, has no neighbours. Only similarities of at least 0 count.

The embedder and stores are used through embed and query only, so importing this module never loads NumPy. With an exact-scan vector store, every query scans every stored chunk.

Raises:

Type Description
ValueError

chunk_pool_factor is less than 1.

kind property

kind: str

neighbours async

neighbours(
    record_ids: Sequence[str], limit: int
) -> dict[str, list[tuple[str, float]]]

Return each record's most similar records, best first.

Raises:

Type Description
EmbeddingError

The embedder returned a different number of vectors than it was given texts.