一个面向 Hexo Markdown 博客的本地语义搜索实验项目。
这个项目的目标是做一个结构清晰、容易学习和维护的 MVP:读取本地博客文章,切分成 chunk,生成 embedding,并写入 Qdrant local mode。之后可以在命令行里输入查询语句,得到相关博客片段。
- 不是完整 RAG 系统
- 不是商业级搜索引擎
- 不是大模型问答系统
- 不接入 OpenAI API
- 不包含登录、前端、Docker 或 CI/CD
semantic-blog-search/
├── README.md
├── requirements.txt
├── .gitignore
├── config.example.yaml
├── src/
│ └── semantic_blog_search/
│ ├── __init__.py
│ ├── config.py
│ ├── parser.py
│ ├── chunker.py
│ ├── embedder.py
│ ├── vector_store.py
│ ├── indexer.py
│ └── searcher.py
├── scripts/
│ ├── build_index.py
│ └── search.py
├── examples/
│ └── posts/
│ └── sample.md
└── tests/
└── test_chunker.py建议先创建虚拟环境:
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txtWindows 用户激活虚拟环境:
.venv\Scripts\activatecp config.example.yaml config.yamlWindows PowerShell 可以使用:
Copy-Item config.example.yaml config.yaml然后检查 config.yaml 里的 posts_dir 是否指向你的 Hexo Markdown 文章目录。
python scripts/build_index.py --config config.yaml第一次运行会下载 embedding 模型,可能需要一些时间。
python scripts/search.py --config config.yaml "线段树懒标记为什么要 pushdown"搜索结果会输出标题、URL、相似度分数、来源文件和片段。
日常更新博客后,建议使用增量同步:
python scripts/sync_index.py --config config.yaml它会记录每篇 Markdown 文件的内容 hash:
- 新增文章:解析、切块、embedding、写入 Qdrant
- 修改文章:删除旧 chunk,再写入新 chunk
- 删除文章:删除对应 chunk
- 未变化文章:跳过,不重新 embedding
索引状态默认保存在:
./data/index_manifest.jsondata/ 已经在 .gitignore 中,不应该提交到 GitHub。
默认搜索现在使用轻量混合排序:
向量语义召回 -> 取更多候选 chunk -> 关键词/标题/标签加分 -> 返回 top_k它适合算法博客里常见的精确术语,例如“按秩合并”“并查集”“pushdown”“lazy tag”。向量搜索负责找语义相近的片段,关键词加分负责避免精确术语被漏掉。
相关配置:
search:
top_k: 5
hybrid_enabled: true
hybrid_candidate_multiplier: 4
hybrid_candidate_limit: 30
hybrid_keyword_weight: 0.25默认切分策略现在会理解 Markdown 标题结构,并切到四级标题:
chunk:
chunk_size: 700
chunk_overlap: 120
split_heading_max_level: 4
include_heading_path: true也就是说,#、##、###、#### 都会作为小节边界。你的算法博客里很多 #### 是题目名,这样搜索会更容易定位到具体题目。
每个 chunk 会带上小节路径,例如:
文章标题:数据结构
标签:数据结构、并查集
小节路径:数据结构 > 并查集 > 家谱
正文:
...##### 及以下默认不强制切分,避免切得太碎。代码块里的 #include 或 #### fake heading 也不会被误判为标题。
如果你刚从旧版本升级,需要重新写入一次索引 payload,让 Qdrant 里保存完整 chunk 文本和标题路径:
python scripts/build_index.py --config config.yaml或者让文章通过增量索引重新写入。否则旧索引只能用较短的 snippet 做关键词匹配,效果会弱一些。
如果你希望接入个人网站后台,可以启动本地 API 服务:
python scripts/server.py --config config.yaml启动前需要在 config.yaml 里配置私有 token:
server:
host: "127.0.0.1"
port: 8000
api_token: "replace-with-a-long-random-token"默认地址:
http://127.0.0.1:8000健康检查:
curl "http://127.0.0.1:8000/health"搜索接口:
curl -H "Authorization: Bearer replace-with-a-long-random-token" "http://127.0.0.1:8000/search?q=线段树懒标记为什么要 pushdown&top_k=5"这个服务启动时会加载一次 embedding 模型,并让模型常驻内存。之后每次搜索请求都会复用同一个模型实例,因此比反复执行命令行搜索更适合网站后台调用。
部署到 api.keronshans.top 的详细步骤见:
docs/deploy-to-keronshans.md当前线上推荐使用 Cloudflare Tunnel 暴露搜索 API,而不是直接把服务器公网 IP 或 8080 端口暴露出去。
整体链路是:
keronshans.top 页面
-> /api/blog-search
-> https://api.keronshans.top/search
-> Cloudflare Tunnel
-> 腾讯云服务器 127.0.0.1:8000
-> semantic-blog-search FastAPI这样做的原因:
- Python 搜索服务仍然只监听
127.0.0.1:8000,不直接暴露公网端口。 - Cloudflare 负责 HTTPS 和公网入口。
- 避免直接访问腾讯云 IP、备案页、DNS-only/proxied 混用带来的不稳定。
- 网站前端仍然只请求主站自己的
/api/blog-search,不会暴露 Bearer Token。
当前 Cloudflare 侧配置:
Tunnel name: semantic-blog-search
Public hostname: api.keronshans.top
Tunnel service: http://127.0.0.1:8000
DNS: api.keronshans.top CNAME <tunnel-id>.cfargotunnel.com服务器上需要常驻两个服务:
systemctl status semantic-blog-search --no-pager
systemctl status cloudflared --no-pager验证公网入口:
curl https://api.keronshans.top/health
curl "https://api.keronshans.top/search?q=线段树&top_k=5"
curl -H "Authorization: Bearer <token>" "https://api.keronshans.top/search?q=线段树&top_k=5"预期结果:
/health返回status: ok。- 不带 token 的
/search返回401。 - 带 token 的
/search返回 JSON 搜索结果。
posts_dir:Hexo Markdown 文章目录。url_prefix:生成文章 URL 时使用的前缀,例如/posts。qdrant.db_path:Qdrant local mode 的本地数据目录。qdrant.collection_name:Qdrant collection 名称。embedding.model_name:sentence-transformers 使用的 embedding 模型。chunk.chunk_size:每个 chunk 目标字符数。chunk.chunk_overlap:相邻 chunk 之间保留的重叠字符数。chunk.split_heading_max_level:按几级 Markdown 标题切分;默认4表示切到####。chunk.include_heading_path:是否把小节路径写入 embedding 文本和 payload。search.top_k:默认返回的搜索结果数量。search.hybrid_enabled:是否启用混合搜索排序。search.hybrid_candidate_multiplier:向量召回候选数相对top_k的倍数。search.hybrid_candidate_limit:混合搜索最多召回多少候选 chunk。search.hybrid_keyword_weight:关键词分数对最终排序的影响权重。index.manifest_path:增量索引状态文件路径。server.host:API 服务监听地址。本地使用127.0.0.1,云平台可能需要0.0.0.0。server.port:API 服务端口。server.api_token:保护/search接口的 Bearer Token,不要提交到 GitHub。
- 支持更好的 Markdown 清洗,例如去掉代码块或图片语法。
- 支持按 tags、日期范围过滤搜索结果。
- 支持更友好的 snippet 生成策略。
- 增加更多解析和搜索测试。
这个 MVP 只做语义检索 API 和命令行搜索。它不会生成答案,也不会理解复杂查询语法。chunk 切分策略比较朴素,主要按段落拼接;如果文章里有大量代码或表格,搜索片段可能还不够理想。