qmd_py.search.fts¶
Full-text (BM25) search: build_ts_query(), the generated tsvector
column, and search_fts().
fts ¶
Full-text search: tsvector/tsquery machinery, ported from the TS
reference's buildTsQuery/searchFTS/updateDocumentSearchVector
(src/store.ts) - see the qmd-py plan's "Algorithmic details" section for
why the weight scaling and dotted-token handling are shaped this way.
Deliberately does not import from store.py: collection/ACL resolution is
duplicated locally (a few lines) rather than reused, so store.py can
import this module (to call update_document_search_vector after every
document write) without a circular import.
SearchResult
dataclass
¶
SearchResult(
filepath: str,
display_path: str,
title: str,
hash: str,
docid: str,
collection_name: str,
modified_at: datetime,
body_length: int,
body: str,
context: str | None,
score: float,
source: str = "fts",
chunk_pos: int | None = None,
)
One search hit, shared by lexical and vector search.
One shape for both so cli/formatter.py can render either without
caring which produced it; source says which did, and chunk_pos is
only meaningful for one of them.
Attributes:
-
filepath(str) –Virtual URI,
marq://<collection>/<path>. -
display_path(str) –<collection>/<path>, as printed. -
title(str) –Extracted heading or filename stem.
-
hash(str) –Full content hash.
-
docid(str) –Its six-char prefix.
-
collection_name(str) –Owning collection.
-
modified_at(datetime) –Source file mtime recorded at index time.
-
body_length(int) –Character count of
body. -
body(str) –Full document text - the whole body, not the matched part.
-
context(str | None) –Hierarchical context for this path, or None.
-
score(float) –Relevance. Not comparable across sources:
ts_rankoutput for lexical,1 - cosine_distancefor vector. -
source(str) –"fts"or"vec". -
chunk_pos(int | None) –Character offset of the matching chunk, set by vector search only, so snippets anchor on the hit instead of the top of the document.
chunk_pos
class-attribute
instance-attribute
¶
Character position of the matching chunk - only set by vector search, used to anchor snippet extraction near the actual hit rather than the start of the document.
normalize_cjk_for_fts ¶
Postgres's built-in text search configs don't segment CJK runs into words (same gap FTS5's unicode61 tokenizer has) - space out every character in a CJK run so it can be treated as an adjacency phrase.
Source code in src/qmd_py/search/fts.py
contains_cjk ¶
Whether text holds any character from the covered CJK blocks.
Covers the common BMP blocks only, not every rare extension - see
_CJK_RANGES.
sanitize_fts_term ¶
Strip a term down to what is safe inside a to_tsquery expression.
Keeps letters, digits, apostrophes and underscores; drops everything
else and lowercases the rest. That removal is what makes the query
injection-safe, since the result is interpolated into tsquery syntax
rather than bound as a parameter. Leading/trailing apostrophes are
stripped too: in tsquery input ' is the lexeme-quote character, so a
term starting with one opens a quoted lexeme that never closes
('n:* is a syntax error) - and a lexeme can't start or end with an
apostrophe anyway. Interior ones (don't) are legal and kept.
Returns:
-
str–The sanitized term, possibly empty - callers must drop empties
-
str–rather than emit a bare operator.
Source code in src/qmd_py/search/fts.py
is_hyphenated_token ¶
True for compound words like multi-agent, DEC-0054, gpt-4: starts and ends with a letter/number, contains at least one internal hyphen, and every character is a letter/number/apostrophe/hyphen.
Source code in src/qmd_py/search/fts.py
is_dotted_token ¶
True for version-like strings (2026.4.10, 3.14.0): splitting on dots yields >= 2 non-empty word/number/underscore parts.
Source code in src/qmd_py/search/fts.py
build_ts_query ¶
Parse lex query syntax into a Postgres to_tsquery expression.
Supports the same surface syntax as the TS reference's FTS5-era parser:
- Quoted phrases: "exact phrase" -> word <-> word (adjacency)
- Negation: -term or -"phrase" -> tsquery's unary !
- Hyphenated tokens: multi-agent, DEC-0054 -> phrase (not prefix) match
- Dotted tokens: 2026.4.10 -> the whole dotted string, prefix-matched
(Postgres's tokenizer keeps these as one lexeme, unlike SQLite FTS5,
so - unlike the TS version - the parts are NOT split and ANDed)
- Plain terms: term -> term:* (prefix match)
Negative-only queries return None (no results), matching the original's
restriction even though tsquery's ! is technically usable standalone.
Source code in src/qmd_py/search/fts.py
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 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 | |
validate_semantic_query ¶
Check a vec:/hyde: sub-query for lex-only syntax.
Returns:
-
str | None–An actionable message, or None if the query is fine. Only
-
str | None–applied to sub-queries a caller spelled out - never to
-
str | None–expand_query()'s generated variants; see -
str | None–hybrid.validate_typed_queries().
Source code in src/qmd_py/search/fts.py
validate_lex_query ¶
Check a lex: sub-query for syntax build_ts_query can't honour.
Catches a newline (each lex line is parsed separately) and an unmatched double quote, which would otherwise run to end of input and silently swallow the rest of the query as one phrase.
Returns:
-
str | None–An actionable message, or None if the query is fine.
Source code in src/qmd_py/search/fts.py
update_document_search_vector
async
¶
Recompute a document's search_vector from its current title,
display path, and body. Not a Postgres GENERATED column (the body
lives in Content.doc, joined via hash - a generated column's
expression can't reach across tables) - called wherever a document's
title/path/body changes (see store.py's insert_document/update_document).
Weights mirror the original SQLite FTS5 bm25(documents_fts, 1.5, 4.0,
1.0) weighting (filepath, title, body): title matters most, filepath
next, body is the baseline - setweight()'s A/B/C zones encode that
order; the actual ratio is applied at query time via ts_rank's weights
array (see FTS_RANK_WEIGHTS below).
Source code in src/qmd_py/search/fts.py
get_docid ¶
The short document id shown in output and accepted as #abc123.
Six hex chars of the content hash. Short enough to collide in
principle; find_document() resolves collisions deterministically
rather than uniquely.
Source code in src/qmd_py/search/fts.py
get_context_for_path
async
¶
get_context_for_path(
session: AsyncSession,
user: CurrentUser,
collection_id: int,
path: str,
) -> str | None
Hierarchical context: the user's global context (context add /)
first, then all matching per-path CollectionContext rows for this
collection, shortest/most-general prefix first - port of the TS
reference's getContextForPath.
Source code in src/qmd_py/search/fts.py
search_fts
async
¶
search_fts(
session: AsyncSession,
user: CurrentUser,
query: str,
limit: int = 20,
collection_name: str | None = None,
) -> list[SearchResult]
Rank documents lexically with Postgres full-text search.
Parameters:
-
query(str) –Lex syntax - prefix terms,
"quoted phrases",-negation. Parsed bybuild_ts_query(). -
limit(int, default:20) –Maximum hits.
-
collection_name(str | None, default:None) –Restrict to one collection; None searches every collection the user can read.
Returns:
-
list[SearchResult]–Hits ordered by descending rank,
source="fts". Empty when the -
list[SearchResult]–query has no positive term (a negation-only query), when no
-
list[SearchResult]–collection is accessible, or when nothing matches - none of which
-
list[SearchResult]–is an error.
Note
Fetches one context per hit (an N+1). Bounded by limit, so it
is a fixed small cost per search rather than a scaling one.
Source code in src/qmd_py/search/fts.py
399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 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 | |