用 Claude Code 实现搜索功能:Postgres、Meilisearch 与 Algolia 实战
从需求提示词、索引设计、同步任务、筛选、前端防抖到测试,系统讲解 Claude Code 搜索功能实现。
搜索功能不只是一个输入框
搜索功能是根据用户输入寻找候选内容,并返回筛选、排序和高亮结果的完整体验。博客、课程目录、SaaS 帮助中心都不能只依赖LIKE '%keyword%'。真正能带来 PV 增长和转化的搜索,需要让用户用模糊词也能找到合适内容,并把 0 结果查询变成新的选题线索。
Masa 在内容站点上做搜索时踩过的坑是:先做 UI,后补索引和权限规则。结果页面看起来完成了,但哪些字段可以进入索引、草稿如何排除、多语言 URL 如何生成、点击日志如何回流优化都没有定义。Claude Code 可以很快写代码,但你需要先给它清晰的搜索规格。
延伸阅读可以看 Claude Code Algolia 搜索、Claude Code API 开发 和 Claude Code 性能优化。
先确认 3 个以上的使用场景
| 场景 | 示例 | 关键点 | 推荐方案 |
|---|---|---|---|
| 内容搜索 | 博客、FAQ、文档 | 标题权重、摘要、标签、语言 | Postgres 全文搜索或 Meilisearch |
| 商品/课程搜索 | 模板、课程、商品 | facet、排序、同义词、点击分析 | Meilisearch 或 Algolia |
| 管理后台搜索 | 客户、账单、日志 | 权限、精确筛选、审计 | Postgres 优先 |
| 多语言搜索 | 中文、英文、日文页面 | locale 分离、本地关键词 | Meilisearch 或 Algolia |
如果内容量不大,并且数据已经在 Postgres 中,可以先用数据库全文搜索。需要 typo tolerance、facet 和更好的默认相关性时,再切换到 Meilisearch。搜索直接影响收入、课程购买或商品转化时,Algolia 的 UI 组件和分析能力会更有价值。
给 Claude Code 的需求提示词
你要在现有 Next.js 应用中实现生产级搜索功能。
目标:
- 搜索已发布文章,提升内容发现和页面回游。
- 支持 query、locale、category、tags 筛选。
- 搜索 title、summary、tags、body,并让 title 权重最高。
- 返回搜索结果卡片需要的字段和高亮信息。
约束:
- 不返回草稿、私有记录、邮箱、内部备注或受限内容。
- 不把 admin key 或 write key 暴露给浏览器。
- 前端使用 300ms debounce 和 AbortController。
- 记录 0 结果查询、慢查询和点击结果。
交付物:
- Postgres 全文搜索、Meilisearch、Algolia 的选择说明。
- 索引 schema。
- 同步任务。
- /api/search 路由。
- React 搜索 UI。
- 测试和上线检查清单。
让 Claude Code 先阅读现有 schema、文章 frontmatter、认证规则和 URL 结构,再开始修改文件。搜索最危险的问题通常不是「找不到」,而是「不该被找到的内容被找到了」。
选择 Postgres、Meilisearch 还是 Algolia
PostgreSQL 官方的 Full Text Search 文档覆盖了tsvector、tsquery、ranking 和 highlight,适合权限条件复杂、数据本来就在数据库中的系统。Meilisearch 的 quick start 和 filtering/sorting/faceting 适合快速做内容搜索。Algolia 的 InstantSearch.js 和 React InstantSearch 更适合搜索和转化强相关的产品。
Postgres 索引 schema
下面的 SQL 可以作为最小起点。重点是把标题、摘要、标签、正文拆成不同权重,而不是每次查询时临时扫描全文。
CREATE TABLE IF NOT EXISTS articles (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL UNIQUE,
locale text NOT NULL,
status text NOT NULL CHECK (status IN ('draft', 'published', 'private')),
title text NOT NULL,
summary text NOT NULL,
body text NOT NULL,
category text NOT NULL,
tags text[] NOT NULL DEFAULT '{}',
popularity integer NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL DEFAULT now(),
search_vector tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('simple', coalesce(title, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(summary, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(array_to_string(tags, ' '), '')), 'B') ||
setweight(to_tsvector('simple', coalesce(body, '')), 'C')
) STORED
);
CREATE INDEX IF NOT EXISTS articles_search_vector_idx
ON articles USING GIN (search_vector);
CREATE INDEX IF NOT EXISTS articles_locale_status_idx
ON articles (locale, status, updated_at DESC);
中文搜索如果要达到更高召回率,需要额外考虑分词。内容站点早期可以先用标题、标签和摘要优化;当 0 结果查询增加后,再评估 Meilisearch 或 Algolia。
Meilisearch 同步任务
外部搜索引擎不要成为数据源。数据库或 CMS 才是 source of truth,同步时只发送公开字段。
// scripts/sync-meilisearch.ts
import "dotenv/config";
import { MeiliSearch } from "meilisearch";
type ArticleRecord = {
id: string;
title: string;
summary: string;
body: string;
locale: string;
status: "published";
category: string;
tags: string[];
url: string;
popularity: number;
updatedAtTimestamp: number;
};
const client = new MeiliSearch({
host: process.env.MEILISEARCH_HOST ?? "http://127.0.0.1:7700",
apiKey: process.env.MEILISEARCH_ADMIN_KEY
});
const index = client.index<ArticleRecord>("articles");
await index.updateSettings({
searchableAttributes: ["title", "summary", "body", "tags"],
filterableAttributes: ["locale", "status", "category", "tags"],
sortableAttributes: ["updatedAtTimestamp", "popularity"],
displayedAttributes: ["id", "title", "summary", "locale", "category", "tags", "url"]
});
const task = await index.addDocuments(
[
{
id: "zh_claude-code-search-functionality",
title: "用 Claude Code 实现搜索功能",
summary: "从索引设计到 UI 和测试的搜索功能实战指南。",
body: "从公开 MDX 或 CMS 中抽取的正文搜索文本。",
locale: "zh",
status: "published",
category: "use-cases",
tags: ["Claude Code", "搜索功能", "全文搜索"],
url: "/zh/blog/claude-code-search-functionality",
popularity: 18,
updatedAtTimestamp: 1780272000
}
],
{ primaryKey: "id" }
);
console.log(`Queued Meilisearch task ${task.taskUid}`);
facet 不要无限增加。内容站点通常先放category、tags、locale就够了;权限过滤要放在服务端或搜索 key 限制里,不要交给用户输入决定。
带 debounce 的 React UI
前端要避免每个字符都触发请求,也要取消过期请求。
// components/ArticleSearchBox.tsx
"use client";
import { useEffect, useMemo, useState } from "react";
type SearchHit = {
id: string;
title: string;
summary: string;
url: string;
category: string;
};
function useDebounce<T>(value: T, delayMs: number) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = window.setTimeout(() => setDebounced(value), delayMs);
return () => window.clearTimeout(timer);
}, [value, delayMs]);
return debounced;
}
export function ArticleSearchBox({ locale = "zh" }: { locale?: string }) {
const [query, setQuery] = useState("");
const [category, setCategory] = useState("");
const [hits, setHits] = useState<SearchHit[]>([]);
const [loading, setLoading] = useState(false);
const debouncedQuery = useDebounce(query, 300);
const params = useMemo(() => {
const next = new URLSearchParams({ q: debouncedQuery, locale });
if (category) next.set("category", category);
return next;
}, [category, debouncedQuery, locale]);
useEffect(() => {
if (debouncedQuery.trim().length < 2) {
setHits([]);
return;
}
const controller = new AbortController();
setLoading(true);
fetch(`/api/search?${params.toString()}`, { signal: controller.signal })
.then((response) => {
if (!response.ok) throw new Error("Search request failed");
return response.json();
})
.then((data: { hits: SearchHit[] }) => setHits(data.hits))
.catch((error) => {
if (error.name !== "AbortError") console.error(error);
})
.finally(() => setLoading(false));
return () => controller.abort();
}, [debouncedQuery, params]);
return (
<section aria-label="文章搜索">
<input
aria-label="搜索关键词"
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索 Claude Code 文章"
/>
<select aria-label="分类" value={category} onChange={(event) => setCategory(event.target.value)}>
<option value="">全部</option>
<option value="use-cases">使用场景</option>
<option value="advanced">进阶</option>
</select>
{loading && <p>搜索中...</p>}
<ul>
{hits.map((hit) => (
<li key={hit.id}>
<a href={hit.url}>{hit.title}</a>
<p>{hit.summary}</p>
</li>
))}
</ul>
</section>
);
}
测试、上线和常见坑
最常见的坑是把草稿或私有字段送进索引、把管理 key 放到浏览器、同义词加得太多、facet 直接照搬数据库列、以及上线后不看 0 结果查询。测试至少覆盖短查询不触发、只返回 published、分类筛选有效、移动端不遮挡结果。
// tests/search-query.test.ts
import { describe, expect, it } from "vitest";
function shouldSearch(query: string) {
return query.trim().length >= 2 && query.length <= 80;
}
describe("search request rules", () => {
it("rejects empty and one-character queries", () => {
expect(shouldSearch("")).toBe(false);
expect(shouldSearch("a")).toBe(false);
expect(shouldSearch("api")).toBe(true);
});
});
上线前检查:索引只含公开数据、0 结果 UI 可读、p95 延迟达标、超长查询被截断、日志不保存个人信息、搜索入口和内部链接自然连接。上线后每周把搜索日志交给 Claude Code,让它提出标题、同义词、内部链接和新文章选题的改进。
ClaudeCodeLab 可以提供 Claude Code 搜索设计、API 实装和内容回游改善的培训与咨询。需要系统化支持时,可以从培训与咨询页面开始。
总结
搜索功能的顺序应该是:需求、技术选择、索引 schema、同步任务、筛选与 facet、前端 debounce、测试、上线。实际试下来,越早限制索引字段和返回字段,后期修正越少;持续查看 0 结果查询,则最容易发现能带来 PV 增长的新内容主题。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。