feat(ebook): implement phrase matching functionality and UI enhancements
This commit is contained in:
@@ -36,12 +36,13 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|||||||
app.state.config = config
|
app.state.config = config
|
||||||
logger.info(
|
logger.info(
|
||||||
"ebook_search_config_loaded top_k=%s embedding_model=%s embedding_base_url=%s vllm_base_url=%s "
|
"ebook_search_config_loaded top_k=%s embedding_model=%s embedding_base_url=%s vllm_base_url=%s "
|
||||||
"rerank_enabled=%s answer_enabled=%s library_paths=%s",
|
"rerank_enabled=%s phrase_matching_enabled=%s answer_enabled=%s library_paths=%s",
|
||||||
config.top_k,
|
config.top_k,
|
||||||
config.embedding_model,
|
config.embedding_model,
|
||||||
config.embedding_base_url,
|
config.embedding_base_url,
|
||||||
config.vllm_base_url,
|
config.vllm_base_url,
|
||||||
config.rerank.enabled,
|
config.rerank.enabled,
|
||||||
|
config.phrase_matching_enabled,
|
||||||
config.answer_enabled,
|
config.answer_enabled,
|
||||||
len(config.library_paths),
|
len(config.library_paths),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -80,10 +80,17 @@ def search(
|
|||||||
engine: AppEngine,
|
engine: AppEngine,
|
||||||
query: Annotated[str, Form()],
|
query: Annotated[str, Form()],
|
||||||
rerank: Annotated[str | None, Form()] = None,
|
rerank: Annotated[str | None, Form()] = None,
|
||||||
|
phrase_matching: Annotated[str | None, Form()] = None,
|
||||||
) -> HTMLResponse:
|
) -> HTMLResponse:
|
||||||
"""Run a search and render HTMX results."""
|
"""Run a search and render HTMX results."""
|
||||||
try:
|
try:
|
||||||
response = search_ebooks(engine, query, config, rerank=rerank == "true")
|
response = search_ebooks(
|
||||||
|
engine,
|
||||||
|
query,
|
||||||
|
config,
|
||||||
|
rerank=rerank == "true",
|
||||||
|
phrase_matching=phrase_matching == "true",
|
||||||
|
)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
logger.exception("ebook_search_request_failed")
|
logger.exception("ebook_search_request_failed")
|
||||||
return templates.TemplateResponse(request, "partials/error.html", {"message": str(error)}, status_code=500)
|
return templates.TemplateResponse(request, "partials/error.html", {"message": str(error)}, status_code=500)
|
||||||
|
|||||||
@@ -181,6 +181,12 @@ textarea:focus {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-toggles {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
|
|||||||
@@ -9,10 +9,21 @@
|
|||||||
<label for="query">What are you looking for?</label>
|
<label for="query">What are you looking for?</label>
|
||||||
<textarea id="query" name="query" rows="4" placeholder="Ask a question or paste a passage…" required></textarea>
|
<textarea id="query" name="query" rows="4" placeholder="Ask a question or paste a passage…" required></textarea>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label class="check">
|
<div class="search-toggles">
|
||||||
<input type="checkbox" name="rerank" value="true" {% if config.rerank.enabled %}checked{% endif %}>
|
<label class="check">
|
||||||
Rerank
|
<input type="checkbox" name="rerank" value="true" {% if config.rerank.enabled %}checked{% endif %}>
|
||||||
</label>
|
Rerank
|
||||||
|
</label>
|
||||||
|
<label class="check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="phrase_matching"
|
||||||
|
value="true"
|
||||||
|
{% if config.phrase_matching_enabled %}checked{% endif %}
|
||||||
|
>
|
||||||
|
Phrase matching
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<button type="submit">Search</button>
|
<button type="submit">Search</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ def fetch_bm25_corpus_records(session: Session) -> tuple[list[dict[str, object]]
|
|||||||
select(
|
select(
|
||||||
EbookChunk.id.label("chunk_id"),
|
EbookChunk.id.label("chunk_id"),
|
||||||
EbookChunk.text.label("text"),
|
EbookChunk.text.label("text"),
|
||||||
|
EbookSource.id.label("source_id"),
|
||||||
EbookSource.title.label("source_title"),
|
EbookSource.title.label("source_title"),
|
||||||
EbookSource.author.label("source_author"),
|
EbookSource.author.label("source_author"),
|
||||||
EbookChapter.title.label("chapter_title"),
|
EbookChapter.title.label("chapter_title"),
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ class EbookSearchConfig(BaseSettings):
|
|||||||
protected_phrase_max_candidates_per_book: int = 5000
|
protected_phrase_max_candidates_per_book: int = 5000
|
||||||
protected_phrase_llm_candidates_per_book: int = 500
|
protected_phrase_llm_candidates_per_book: int = 500
|
||||||
protected_phrase_confidence_threshold: float = 0.80
|
protected_phrase_confidence_threshold: float = 0.80
|
||||||
|
phrase_matching_enabled: bool = True
|
||||||
phrase_hit_boost: float = 0.25
|
phrase_hit_boost: float = 0.25
|
||||||
phrase_min_tokens: int = 2
|
phrase_min_tokens: int = 2
|
||||||
phrase_max_tokens: int = 5
|
phrase_max_tokens: int = 5
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import tiktoken
|
|||||||
from sqlalchemy import or_, select
|
from sqlalchemy import or_, select
|
||||||
|
|
||||||
from python.ebook_search.epub_parse import parse_epub
|
from python.ebook_search.epub_parse import parse_epub
|
||||||
|
from python.ebook_search.protected_phrases.lib import index_chunk_phrase_mentions_for_book
|
||||||
from python.orm.richie import EbookChapter, EbookChunk, EbookSource
|
from python.orm.richie import EbookChapter, EbookChunk, EbookSource
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -138,12 +139,14 @@ def ingest_file(session: Session, path: Path, config: EbookSearchConfig) -> bool
|
|||||||
chunk_index = add_chapter_chunks(session, source, chapter, parsed_chapter, chunk_index, config)
|
chunk_index = add_chapter_chunks(session, source, chapter, parsed_chapter, chunk_index, config)
|
||||||
|
|
||||||
session.commit()
|
session.commit()
|
||||||
|
mention_count = index_chunk_phrase_mentions_for_book(session, source.id, config)
|
||||||
logger.info(
|
logger.info(
|
||||||
"ebook_ingest_file_complete source_id=%s path=%s chapters=%s chunks=%s",
|
"ebook_ingest_file_complete source_id=%s path=%s chapters=%s chunks=%s phrase_mentions=%s",
|
||||||
source.id,
|
source.id,
|
||||||
resolved_path,
|
resolved_path,
|
||||||
len(parsed.chapters),
|
len(parsed.chapters),
|
||||||
chunk_index,
|
chunk_index,
|
||||||
|
mention_count,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(f"ebook_ingest_file_error path={path}")
|
logger.exception(f"ebook_ingest_file_error path={path}")
|
||||||
|
|||||||
@@ -1249,8 +1249,7 @@ def judge_candidate_phrases_for_books(
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
logger.info(
|
logger.info(
|
||||||
"ebook_candidate_phrase_judgment_book_start source_id=%s book_number=%s books_seen=%s "
|
"ebook_candidate_phrase_judgment_book_start source_id=%s book_number=%s books_seen=%s title=%r unjudged=%s",
|
||||||
"title=%r unjudged=%s",
|
|
||||||
source.id,
|
source.id,
|
||||||
book_number,
|
book_number,
|
||||||
books_seen,
|
books_seen,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
from pgvector.sqlalchemy import Vector
|
from pgvector.sqlalchemy import Vector
|
||||||
from sqlalchemy import literal, select
|
from sqlalchemy import literal, select
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from python.ebook_search.bm25_corpus import (
|
from python.ebook_search.bm25_corpus import (
|
||||||
@@ -19,6 +20,11 @@ from python.ebook_search.bm25_corpus import (
|
|||||||
score_bm25_corpus,
|
score_bm25_corpus,
|
||||||
)
|
)
|
||||||
from python.ebook_search.embeddings import MODEL_DIMENSIONS, embed_query, get_embedding_table
|
from python.ebook_search.embeddings import MODEL_DIMENSIONS, embed_query, get_embedding_table
|
||||||
|
from python.ebook_search.protected_phrases.lib import (
|
||||||
|
HydratedPhraseMatch,
|
||||||
|
detect_protected_phrases_for_query,
|
||||||
|
phrase_hit_counts_for_chunks,
|
||||||
|
)
|
||||||
from python.ebook_search.rerank import rerank_chunks
|
from python.ebook_search.rerank import rerank_chunks
|
||||||
from python.ebook_search.timing import RuntimeStep, timed_result
|
from python.ebook_search.timing import RuntimeStep, timed_result
|
||||||
from python.orm.richie import (
|
from python.orm.richie import (
|
||||||
@@ -29,7 +35,7 @@ from python.orm.richie import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping, Sequence
|
||||||
|
|
||||||
from sqlalchemy.engine import Engine
|
from sqlalchemy.engine import Engine
|
||||||
|
|
||||||
@@ -45,11 +51,13 @@ class SearchResult:
|
|||||||
chunk_id: int
|
chunk_id: int
|
||||||
text: str
|
text: str
|
||||||
source_title: str
|
source_title: str
|
||||||
|
source_id: int | None = None
|
||||||
score: float = 0.0
|
score: float = 0.0
|
||||||
vector_score: float | None = None
|
vector_score: float | None = None
|
||||||
bm25_score: float | None = None
|
bm25_score: float | None = None
|
||||||
fused_score: float | None = None
|
fused_score: float | None = None
|
||||||
rerank_score: float | None = None
|
rerank_score: float | None = None
|
||||||
|
phrase_hit_count: int = 0
|
||||||
source_author: str | None = None
|
source_author: str | None = None
|
||||||
chapter_title: str | None = None
|
chapter_title: str | None = None
|
||||||
page_label: str | None = None
|
page_label: str | None = None
|
||||||
@@ -64,6 +72,7 @@ class SearchResponse:
|
|||||||
results: list[SearchResult]
|
results: list[SearchResult]
|
||||||
rank_label: str
|
rank_label: str
|
||||||
timings: tuple[RuntimeStep, ...] = ()
|
timings: tuple[RuntimeStep, ...] = ()
|
||||||
|
phrase_matches: tuple[HydratedPhraseMatch, ...] = ()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def total_runtime_ms(self) -> float:
|
def total_runtime_ms(self) -> float:
|
||||||
@@ -86,14 +95,26 @@ def search_ebooks(
|
|||||||
config: EbookSearchConfig,
|
config: EbookSearchConfig,
|
||||||
*,
|
*,
|
||||||
rerank: bool = False,
|
rerank: bool = False,
|
||||||
|
phrase_matching: bool | None = None,
|
||||||
) -> SearchResponse:
|
) -> SearchResponse:
|
||||||
"""Run hybrid vector/BM25 search and optional reranking."""
|
"""Run hybrid vector/BM25 search and optional reranking."""
|
||||||
if not query.strip():
|
if not query.strip():
|
||||||
logger.info("ebook_search_empty_query")
|
logger.info("ebook_search_empty_query")
|
||||||
return SearchResponse(query=query, results=[], rank_label="Hybrid")
|
return SearchResponse(query=query, results=[], rank_label="Hybrid")
|
||||||
|
|
||||||
logger.info("ebook_search_start query_length=%s rerank=%s", len(query), rerank)
|
phrase_matching_enabled = config.phrase_matching_enabled if phrase_matching is None else phrase_matching
|
||||||
|
logger.info(
|
||||||
|
"ebook_search_start query_length=%s rerank=%s phrase_matching=%s",
|
||||||
|
len(query),
|
||||||
|
rerank,
|
||||||
|
phrase_matching_enabled,
|
||||||
|
)
|
||||||
timings: list[RuntimeStep] = []
|
timings: list[RuntimeStep] = []
|
||||||
|
if phrase_matching_enabled:
|
||||||
|
phrase_matches, timing = timed_result("Protected phrase detection", query_phrase_matches, engine, query, config)
|
||||||
|
else:
|
||||||
|
phrase_matches, timing = timed_result("Protected phrase detection skipped", skip_phrase_matches)
|
||||||
|
timings.append(timing)
|
||||||
retrieval, timing = timed_result(
|
retrieval, timing = timed_result(
|
||||||
"Hybrid retrieval",
|
"Hybrid retrieval",
|
||||||
parallel_retrieval,
|
parallel_retrieval,
|
||||||
@@ -111,18 +132,32 @@ def search_ebooks(
|
|||||||
rank_constant=config.rrf_rank_constant,
|
rank_constant=config.rrf_rank_constant,
|
||||||
)
|
)
|
||||||
timings.append(timing)
|
timings.append(timing)
|
||||||
|
if phrase_matching_enabled:
|
||||||
|
fused, timing = timed_result(
|
||||||
|
"Phrase mention boost",
|
||||||
|
apply_phrase_mention_boosts,
|
||||||
|
engine,
|
||||||
|
fused,
|
||||||
|
phrase_matches,
|
||||||
|
config.phrase_hit_boost,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
fused, timing = timed_result("Phrase mention boost skipped", skip_phrase_mention_boosts, fused)
|
||||||
|
timings.append(timing)
|
||||||
if config.rerank.enabled and rerank:
|
if config.rerank.enabled and rerank:
|
||||||
response, timing = timed_result("Rerank", apply_rerank, query, fused, config)
|
response, timing = timed_result("Rerank", apply_rerank, query, fused, config)
|
||||||
else:
|
else:
|
||||||
response, timing = timed_result("Rerank skipped", skip_rerank, query, fused, config)
|
response, timing = timed_result("Rerank skipped", skip_rerank, query, fused, config)
|
||||||
timings.append(timing)
|
timings.append(timing)
|
||||||
response = replace(response, timings=tuple(timings))
|
response = replace(response, timings=tuple(timings), phrase_matches=tuple(phrase_matches))
|
||||||
logger.info(
|
logger.info(
|
||||||
"ebook_search_complete vector_candidates=%s lexical_candidates=%s "
|
"ebook_search_complete vector_candidates=%s lexical_candidates=%s "
|
||||||
"fused_candidates=%s returned=%s rank_label=%s runtime_ms=%.1f",
|
"fused_candidates=%s phrase_matching=%s phrase_matches=%s returned=%s rank_label=%s runtime_ms=%.1f",
|
||||||
len(retrieval.vector_results),
|
len(retrieval.vector_results),
|
||||||
len(retrieval.lexical_results),
|
len(retrieval.lexical_results),
|
||||||
len(fused),
|
len(fused),
|
||||||
|
phrase_matching_enabled,
|
||||||
|
len(phrase_matches),
|
||||||
len(response.results),
|
len(response.results),
|
||||||
response.rank_label,
|
response.rank_label,
|
||||||
response.total_runtime_ms,
|
response.total_runtime_ms,
|
||||||
@@ -130,6 +165,77 @@ def search_ebooks(
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def skip_phrase_matches() -> list[HydratedPhraseMatch]:
|
||||||
|
"""Return no protected phrase matches when phrase matching is disabled."""
|
||||||
|
logger.info("ebook_protected_phrase_detection_skipped")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def query_phrase_matches(engine: Engine, query: str, config: EbookSearchConfig) -> list[HydratedPhraseMatch]:
|
||||||
|
"""Detect protected phrases in a query without making search fail when phrase tables are unavailable."""
|
||||||
|
try:
|
||||||
|
with Session(engine) as session:
|
||||||
|
return detect_protected_phrases_for_query(session, query, config)
|
||||||
|
except SQLAlchemyError as error:
|
||||||
|
logger.warning("ebook_protected_phrase_detection_unavailable error=%s", error)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def skip_phrase_mention_boosts(candidates: list[SearchResult]) -> list[SearchResult]:
|
||||||
|
"""Return candidates unchanged when phrase matching is disabled."""
|
||||||
|
logger.info("ebook_phrase_boost_skipped candidates=%s", len(candidates))
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def apply_phrase_mention_boosts(
|
||||||
|
engine: Engine,
|
||||||
|
candidates: list[SearchResult],
|
||||||
|
phrase_matches: Sequence[HydratedPhraseMatch],
|
||||||
|
phrase_hit_boost: float,
|
||||||
|
) -> list[SearchResult]:
|
||||||
|
"""Boost retrieved chunks that have indexed mentions for detected protected phrases."""
|
||||||
|
phrase_ids = sorted({match.phrase_id for match in phrase_matches})
|
||||||
|
if not candidates or not phrase_ids or phrase_hit_boost <= 0:
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
chunk_ids = [candidate.chunk_id for candidate in candidates]
|
||||||
|
try:
|
||||||
|
with Session(engine) as session:
|
||||||
|
hit_counts = phrase_hit_counts_for_chunks(session, chunk_ids=chunk_ids, phrase_ids=phrase_ids)
|
||||||
|
except SQLAlchemyError as error:
|
||||||
|
logger.warning("ebook_phrase_boost_unavailable error=%s", error)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
if not hit_counts:
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
boosted = [
|
||||||
|
replace(
|
||||||
|
candidate,
|
||||||
|
score=candidate.score + (hit_counts.get(candidate.chunk_id, 0) * phrase_hit_boost),
|
||||||
|
fused_score=boosted_fused_score(candidate, hit_counts.get(candidate.chunk_id, 0), phrase_hit_boost),
|
||||||
|
phrase_hit_count=hit_counts.get(candidate.chunk_id, 0),
|
||||||
|
rank_source=phrase_rank_source(candidate.rank_source, hit_counts.get(candidate.chunk_id, 0)),
|
||||||
|
)
|
||||||
|
for candidate in candidates
|
||||||
|
]
|
||||||
|
return sorted(boosted, key=lambda candidate: candidate.score, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def boosted_fused_score(candidate: SearchResult, phrase_hit_count: int, phrase_hit_boost: float) -> float | None:
|
||||||
|
"""Return a fused score adjusted by phrase hits when a fused score exists."""
|
||||||
|
if candidate.fused_score is None:
|
||||||
|
return None
|
||||||
|
return candidate.fused_score + (phrase_hit_count * phrase_hit_boost)
|
||||||
|
|
||||||
|
|
||||||
|
def phrase_rank_source(rank_source: str, phrase_hit_count: int) -> str:
|
||||||
|
"""Append phrase evidence to a rank-source label when a chunk was boosted."""
|
||||||
|
if phrase_hit_count <= 0 or "phrases" in rank_source:
|
||||||
|
return rank_source
|
||||||
|
return f"{rank_source} + phrases"
|
||||||
|
|
||||||
|
|
||||||
def parallel_retrieval(
|
def parallel_retrieval(
|
||||||
engine: Engine,
|
engine: Engine,
|
||||||
query: str,
|
query: str,
|
||||||
@@ -223,6 +329,7 @@ def vector_candidates(engine: Engine, query: str, config: EbookSearchConfig) ->
|
|||||||
select(
|
select(
|
||||||
EbookChunk.id.label("chunk_id"),
|
EbookChunk.id.label("chunk_id"),
|
||||||
EbookChunk.text.label("text"),
|
EbookChunk.text.label("text"),
|
||||||
|
EbookSource.id.label("source_id"),
|
||||||
EbookSource.title.label("source_title"),
|
EbookSource.title.label("source_title"),
|
||||||
EbookSource.author.label("source_author"),
|
EbookSource.author.label("source_author"),
|
||||||
EbookChapter.title.label("chapter_title"),
|
EbookChapter.title.label("chapter_title"),
|
||||||
@@ -317,9 +424,11 @@ def reciprocal_rank_fusion(
|
|||||||
|
|
||||||
def search_result_from_row(row: Mapping[str, object]) -> SearchResult:
|
def search_result_from_row(row: Mapping[str, object]) -> SearchResult:
|
||||||
"""Convert a database row mapping into a search result."""
|
"""Convert a database row mapping into a search result."""
|
||||||
|
source_id = row.get("source_id")
|
||||||
return SearchResult(
|
return SearchResult(
|
||||||
chunk_id=int(row["chunk_id"]),
|
chunk_id=int(row["chunk_id"]),
|
||||||
text=str(row["text"]),
|
text=str(row["text"]),
|
||||||
|
source_id=int(source_id) if source_id is not None else None,
|
||||||
source_title=str(row["source_title"]),
|
source_title=str(row["source_title"]),
|
||||||
source_author=optional_str(row["source_author"]),
|
source_author=optional_str(row["source_author"]),
|
||||||
chapter_title=optional_str(row["chapter_title"]),
|
chapter_title=optional_str(row["chapter_title"]),
|
||||||
|
|||||||
Reference in New Issue
Block a user