Skip to content

Commit 8a72747

Browse files
authored
Merge pull request #70 from ZimoLiao/issue-65
Fix macOS semantic search crash in FAISS-backed paths
2 parents 4716f2e + 7431413 commit 8a72747

6 files changed

Lines changed: 299 additions & 24 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
name: macOS Semantic Smoke
2+
3+
on:
4+
workflow_dispatch:
5+
pull_request:
6+
branches: [main, master]
7+
paths:
8+
- ".github/workflows/macos-semantic-smoke.yml"
9+
- "pyproject.toml"
10+
- "scholaraio/cli.py"
11+
- "scholaraio/explore.py"
12+
- "scholaraio/index.py"
13+
- "scholaraio/vectors.py"
14+
- "tests/test_cli_messages.py"
15+
- "tests/test_index.py"
16+
- "tests/test_vectors.py"
17+
push:
18+
branches: [main, master]
19+
paths:
20+
- ".github/workflows/macos-semantic-smoke.yml"
21+
- "pyproject.toml"
22+
- "scholaraio/cli.py"
23+
- "scholaraio/explore.py"
24+
- "scholaraio/index.py"
25+
- "scholaraio/vectors.py"
26+
- "tests/test_cli_messages.py"
27+
- "tests/test_index.py"
28+
- "tests/test_vectors.py"
29+
30+
permissions:
31+
contents: read
32+
33+
concurrency:
34+
group: macos-semantic-smoke-${{ github.ref }}
35+
cancel-in-progress: true
36+
37+
jobs:
38+
semantic-smoke:
39+
runs-on: macos-14
40+
timeout-minutes: 30
41+
steps:
42+
- uses: actions/checkout@v4
43+
44+
- uses: actions/setup-python@v5
45+
with:
46+
python-version: "3.11"
47+
cache: "pip"
48+
49+
- name: Install semantic-search dependencies
50+
run: pip install -e ".[dev,embed]"
51+
52+
- name: Smoke test issue-65 explore search flow
53+
env:
54+
HF_HUB_DISABLE_TELEMETRY: "1"
55+
TOKENIZERS_PARALLELISM: "false"
56+
run: |
57+
export SMOKE_ROOT="$RUNNER_TEMP/issue-65-smoke"
58+
mkdir -p "$SMOKE_ROOT/data/explore/issue-65-smoke"
59+
60+
cat > "$SMOKE_ROOT/config.yaml" <<'YAML'
61+
embed:
62+
provider: local
63+
model: sentence-transformers/all-MiniLM-L6-v2
64+
source: huggingface
65+
device: cpu
66+
YAML
67+
68+
cat > "$SMOKE_ROOT/data/explore/issue-65-smoke/papers.jsonl" <<'JSONL'
69+
{"openalex_id":"https://openalex.org/W1","doi":"10.1234/issue65.1","title":"Boundary-layer turbulence transfer learning","abstract":"We study semantic retrieval for boundary layer turbulence with transformer embeddings on CPU systems and compare keyword and unified search behavior.","authors":["Alice Example"],"year":2024,"cited_by_count":7,"type":"article"}
70+
{"openalex_id":"https://openalex.org/W2","doi":"10.1234/issue65.2","title":"Wake mixing in bluff-body flows","abstract":"This paper focuses on wake mixing and provides a distractor document for ranking checks.","authors":["Bob Example"],"year":2023,"cited_by_count":3,"type":"article"}
71+
JSONL
72+
73+
export SCHOLARAIO_CONFIG="$SMOKE_ROOT/config.yaml"
74+
75+
scholaraio explore embed --name issue-65-smoke
76+
77+
scholaraio explore search --name issue-65-smoke "boundary layer turbulence" --mode semantic > "$SMOKE_ROOT/semantic.out"
78+
scholaraio explore search --name issue-65-smoke "boundary layer turbulence" --mode unified > "$SMOKE_ROOT/unified.out"
79+
80+
grep -q "Boundary-layer turbulence transfer learning" "$SMOKE_ROOT/semantic.out"
81+
grep -q "Boundary-layer turbulence transfer learning" "$SMOKE_ROOT/unified.out"
82+
grep -q "分数:" "$SMOKE_ROOT/semantic.out"
83+
grep -q "分数:" "$SMOKE_ROOT/unified.out"
84+
85+
echo "issue-65 macOS explore semantic/unified smoke passed"
86+
87+
- name: Run semantic-search regression tests
88+
run: >
89+
python -m pytest -q -p no:cacheprovider
90+
tests/test_ci_workflows.py
91+
tests/test_vectors.py
92+
tests/test_index.py
93+
tests/test_cli_messages.py
94+
-k "macos_semantic_smoke_workflow_runs_issue_search_commands or embeds_query_before_loading_faiss_index or does_not_embed_when_vector_table_is_empty or unified_search or explore_search_semantic_reports_embedding_disabled_cleanly"

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
99

1010
### Added
1111

12+
- **macOS semantic-search smoke workflow**: Added a dedicated GitHub Actions job on `macos-14` to exercise the `sentence-transformers` -> `faiss-cpu` semantic-search stack and run targeted regression tests for vector-search paths
1213
- **Academic writing router** ([#55](https://github.com/ZimoLiao/scholaraio/issues/55)): Added the `academic-writing` skill as a stable top-level entry point that routes users by deliverable and writing stage instead of forcing them to guess among multiple writing skills
1314
- **Deliverable-first writing workflows** ([#55](https://github.com/ZimoLiao/scholaraio/issues/55)): Added lightweight `poster` and `technical-report` skills so conference posters, poster-style summaries, topic reports, and research briefings are first-class workflows rather than implicit combinations of lower-level skills
1415
- **Writing workflow regression coverage**: Added tests for skill frontmatter validity, router references, approximate host-style skill selection, and 11 rounds of documentation-alignment checks across docs, agent instructions, and marketplace metadata
1516

1617
### Fixed
1718

19+
- **macOS semantic/unified search crash** ([#65](https://github.com/ZimoLiao/scholaraio/issues/65)): main-library and explore semantic search now embed and normalize the query before loading or searching FAISS indexes, avoiding a known `faiss` / `sentence-transformers` import-order segfault pattern on macOS while preserving existing ranking behavior
1820
- **Academic writing docs alignment** ([#55](https://github.com/ZimoLiao/scholaraio/issues/55)): Synchronized `docs/guide/writing.md`, `README.md`, `README_CN.md`, `docs/index.md`, `AGENTS.md`, `AGENTS_CN.md`, `CLAUDE.md`, and `clawhub.yaml` around a router-first writing model so poster/report workflows and the academic-writing entry point are discoverable consistently across user and agent surfaces
1921

2022
## [1.3.1] — 2026-04-14

scholaraio/explore.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -700,10 +700,18 @@ def explore_vsearch(name: str, query: str, *, top_k: int = 10, cfg: Config | Non
700700
Returns:
701701
论文列表,按 cosine similarity 降序。
702702
"""
703-
from scholaraio.vectors import _vsearch_faiss
703+
from scholaraio.vectors import _embed_query_vector, _ensure_vector_search_ready, _vsearch_faiss
704704

705+
db_path = _db_path(name, cfg)
706+
_ensure_vector_search_ready(
707+
db_path,
708+
missing_table_msg=f"向量库为空: {db_path}",
709+
empty_msg=f"向量库为空: {db_path}",
710+
)
711+
# Prepare the query embedding before FAISS import/load to avoid macOS crashes.
712+
q_vec = _embed_query_vector(query, cfg)
705713
index, paper_ids = _build_faiss_index(name, cfg)
706-
hits = _vsearch_faiss(query, index, paper_ids, top_k, cfg=cfg)
714+
hits = _vsearch_faiss(q_vec, index, paper_ids, top_k, cfg=cfg)
707715

708716
paper_map = {}
709717
for p in iter_papers(name, cfg):

scholaraio/vectors.py

Lines changed: 43 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -717,6 +717,40 @@ def _embed_batch(texts: list[str], cfg: Config | None = None) -> list[list[float
717717
raise ValueError(f"未知 embedding provider: {provider}")
718718

719719

720+
def _embed_query_vector(query: str, cfg: Config | None = None):
721+
"""Embed and L2-normalize a single query before touching FAISS."""
722+
import numpy as np
723+
724+
q_vec = np.array([_embed_text(query, cfg)], dtype="float32")
725+
norms = np.linalg.norm(q_vec, axis=1, keepdims=True)
726+
norms[norms == 0] = 1.0
727+
return q_vec / norms
728+
729+
730+
def _ensure_vector_search_ready(
731+
db_path: Path,
732+
*,
733+
missing_table_msg: str,
734+
empty_msg: str,
735+
table_name: str = "paper_vectors",
736+
) -> None:
737+
"""Verify the vector table exists and has at least one row before embedding queries."""
738+
conn = sqlite3.connect(db_path)
739+
try:
740+
has_vectors = conn.execute(
741+
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
742+
(table_name,),
743+
).fetchone()
744+
if not has_vectors:
745+
raise FileNotFoundError(missing_table_msg)
746+
747+
has_rows = conn.execute(f"SELECT 1 FROM {table_name} LIMIT 1").fetchone()
748+
if not has_rows:
749+
raise FileNotFoundError(empty_msg)
750+
finally:
751+
conn.close()
752+
753+
720754
class QwenEmbedder:
721755
"""BERTopic-compatible embedder wrapping the configured embedding backend.
722756
@@ -1028,7 +1062,7 @@ def _build_faiss_index(db_path: Path) -> tuple[faiss.Index, list[str]]:
10281062

10291063

10301064
def _vsearch_faiss(
1031-
query: str,
1065+
query: str | object,
10321066
index: faiss.Index,
10331067
paper_ids: list[str],
10341068
top_k: int,
@@ -1046,14 +1080,10 @@ def _vsearch_faiss(
10461080
Returns:
10471081
List of ``(paper_id, score)`` sorted by descending similarity.
10481082
"""
1049-
import faiss
1050-
import numpy as np
1051-
10521083
if _embed_provider(cfg) == "none":
10531084
raise FileNotFoundError("当前 embed.provider=none,已禁用语义向量检索")
10541085

1055-
q_vec = np.array([_embed_text(query, cfg)], dtype="float32")
1056-
faiss.normalize_L2(q_vec)
1086+
q_vec = _embed_query_vector(query, cfg) if isinstance(query, str) else query
10571087

10581088
fetch_k = min(top_k, index.ntotal)
10591089
scores, indices = index.search(q_vec, fetch_k)
@@ -1099,9 +1129,6 @@ def vsearch(
10991129
Raises:
11001130
FileNotFoundError: 索引文件或 ``paper_vectors`` 表不存在。
11011131
"""
1102-
import faiss
1103-
import numpy as np
1104-
11051132
if _embed_provider(cfg) == "none":
11061133
raise FileNotFoundError("当前 embed.provider=none,已禁用语义向量检索")
11071134

@@ -1111,21 +1138,16 @@ def vsearch(
11111138
if not db_path.exists():
11121139
raise FileNotFoundError(f"索引文件不存在:{db_path}\n请先运行 `scholaraio index`")
11131140

1114-
conn = sqlite3.connect(db_path)
1115-
try:
1116-
has_vectors = conn.execute(
1117-
"SELECT name FROM sqlite_master WHERE type='table' AND name='paper_vectors'"
1118-
).fetchone()
1119-
if not has_vectors:
1120-
raise FileNotFoundError("向量索引不存在,请先运行 `scholaraio embed`")
1121-
finally:
1122-
conn.close()
1141+
_ensure_vector_search_ready(
1142+
db_path,
1143+
missing_table_msg="向量索引不存在,请先运行 `scholaraio embed`",
1144+
empty_msg="向量索引为空,请先运行 `scholaraio embed`",
1145+
)
11231146

1147+
# Load the embedding model before FAISS to avoid known faiss/torch crashes on macOS.
1148+
q_vec = _embed_query_vector(query, cfg)
11241149
index, faiss_ids = _build_faiss_index(db_path)
11251150

1126-
q_vec = np.array([_embed_text(query, cfg)], dtype="float32")
1127-
faiss.normalize_L2(q_vec)
1128-
11291151
# Fetch more candidates when post-filtering is needed
11301152
fetch_k = top_k * 5 if (year or journal or paper_type or paper_ids) else top_k
11311153
fetch_k = min(fetch_k, index.ntotal)

tests/test_ci_workflows.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
5+
import yaml
6+
7+
8+
def test_macos_semantic_smoke_workflow_runs_issue_search_commands() -> None:
9+
workflow = yaml.safe_load(Path(".github/workflows/macos-semantic-smoke.yml").read_text(encoding="utf-8"))
10+
steps = workflow["jobs"]["semantic-smoke"]["steps"]
11+
12+
smoke_step = next(step for step in steps if step.get("name") == "Smoke test issue-65 explore search flow")
13+
run_script = smoke_step["run"]
14+
15+
assert "scholaraio explore embed --name issue-65-smoke" in run_script
16+
assert 'scholaraio explore search --name issue-65-smoke "boundary layer turbulence" --mode semantic' in run_script
17+
assert 'scholaraio explore search --name issue-65-smoke "boundary layer turbulence" --mode unified' in run_script

0 commit comments

Comments
 (0)