[控场AI]
· 9 分钟阅读· 4,901 字

FastAPI+PostgreSQL多语言全文搜索架构实战

FastAPI+PostgreSQL多语言全文搜索架构实战

LectuLibre 用 FastAPI + PostgreSQL 原生全文搜索支撑多语言图书检索,替代 Elasticsearch 并把延迟压到毫秒级。

LectuLibre 是一个 AI 图书翻译平台,允许同一本书以多语言版本共存,并需要用户用母语检索原文与译文。团队放弃了 Elasticsearch,选择 FastAPI + PostgreSQL 的路线:把每条翻译规范化为独立表行并标注 ISO 639-1 语言代码,按语言建立 tsvector 生成列与 GIN 索引,用触发器自动同步搜索向量,借助 websearch_to_tsquery、ts_headline、ts_rank 在单条 SQL 中完成过滤、高亮与排序。此外启用 pg_trgm 解决书名拼写错误的模糊匹配。优化后跨 10 万条翻译的搜索从 800ms 降至 20-40ms,模糊搜索从 1.2s 降至 15ms。团队同时指出,百万级规模或分面搜索仍需重估 Elasticsearch,而中文、日文等 CJK 分词仍是未解难题。

当同一本书需要以西班牙语、法语、德语等多个版本同时存在时,搜索系统的设计难度会急剧上升。LectuLibre 团队在构建 AI 图书翻译平台时,选择了一条看似保守却极为务实的路线:不引入 Elasticsearch,而是用 FastAPI + PostgreSQL 原生全文搜索撑起多语言检索。他们的经验对中小规模 AI 应用开发者有很强的参考价值。

RSS source

问题定义:多语言图书库的搜索挑战

LectuLibre 允许用户上传 EPUB 或 PDF 图书,系统调用 Claude、DeepSeek广告 等大模型完成翻译,最终形成同一本书在多种语言下并存的图书馆。这对后端提出三个硬性要求:高效存储原文与译文;在所有语言中执行快速全文搜索,让用户用母语找到目标书籍或段落;异步管理翻译任务而不阻塞 API。

许多团队遇到这类需求的第一反应是上 Elasticsearch。但 LectuLibre 评估后发现,在单台 VPS、几十万文档的规模下,PostgreSQL 内置的全文搜索已经足够,并且能省去一整套运维负担。

架构决策:不引入额外基础设施

技术栈选择 Python/FastAPI 加 PostgreSQL,ORM 使用 SQLAlchemy 2.0 异步模式配合 asyncpg 驱动,迁移由 Alembic 管理。核心设计围绕四个决策展开:

  • 规范化翻译表:每条翻译作为独立行存储,带 ISO 639-1 语言代码,而非挤进 JSON blob。
  • 生成式 tsvector 列:每种语言对应独立的 tsvector 列并建立 GIN 索引。
  • 语言专属文本搜索配置:使用 english、spanish、french 等内置配置,未知语言回退到 simple。
  • pg_trgm 模糊匹配:借助三元组相似度处理书名拼写错误。

这里有一个值得注意的踩坑教训:团队最初把翻译存成 JSON 列,查询时需要不断解析 JSON,既痛苦又缓慢。规范化到独立表之后,一切变得干净,也能直接利用 PostgreSQL 的全文搜索能力。

tsvector 与 GIN 索引:tsvector 是 PostgreSQL 专为全文搜索设计的数据类型,它把文本拆分为词元(lexeme)并按位置归一化存储,从而支持词干还原(如把 running、runs 归为 run)和位置相关的排序。生成式列(generated column)允许这一向量由数据库在写入时自动计算,应用层无需手动维护。GIN(Generalized Inverted Index)是 PostgreSQL 针对数组、JSONB、tsvector 这类“一个值包含多个键”的数据结构的倒排索引。相较于 B-tree 只能做前缀或等值匹配,GIN 索引让 @@ 匹配无需全表扫描,这正是搜索从毫秒级退化到秒级的根本原因。

代码主线:模型、迁移与搜索端点

三张核心表

books、chapters、translations 构成数据骨架。translations 表包含 chapter_id、language、content 以及 content_tsv 列。关键点是 content_tsv 不在 Python 侧维护,而是在迁移中以生成列方式创建,由数据库负责生成逻辑。

class Translation(Base):
    __tablename__ = "translations"
    id: Mapped[int] = mapped_column(primary_key=True)
    chapter_id: Mapped[int] = mapped_column(ForeignKey("chapters.id"))
    language: Mapped[str] = mapped_column(String(10))
    content: Mapped[str] = mapped_column(Text)
    content_tsv: Mapped[str] = mapped_column(TSVECTOR, nullable=True)

迁移与触发器

Alembic 迁移中添加 tsvector 列后,用 UPDATE 语句按语言选择对应的文本搜索配置(en→english,es→spanish,fr→french,其余→simple),再创建 GIN 索引。为了在内容变更时持续同步,团队写了一个 PostgreSQL 触发器函数,在 INSERT 或 UPDATE 之前自动重算 content_tsv。这样应用层完全不需要关心索引更新。

搜索端点

FastAPI 端点接收 q、lang、limit 参数,将语言代码映射到 PostgreSQL 配置后,构建一条 SQL 查询完成所有工作:用 websearch_to_tsquery 解析用户输入(支持引号和 OR 语法),@@ 运算符过滤匹配行,ts_headline 生成带 <mark> 标签的高亮摘要,ts_rank 按相关度排序。整个搜索、摘要、排序在单次 SQL 中闭环。

stmt = (
    select(
        Translation.id,
        Translation.language,
        Chapter.order,
        Book.title,
        func.ts_headline(
            ts_config,
            Translation.content,
            func.websearch_to_tsquery(ts_config, q),
            'StartSel=<mark>, StopSel=</mark>, MaxWords=50, MinWords=20'
        ).label("snippet"),
    )
    .join(Chapter, Translation.chapter_id == Chapter.id)
    .join(Book, Chapter.book_id == Book.id)
    .where(
        Translation.language == lang,
        Translation.content_tsv @@ func.websearch_to_tsquery(ts_config, q)
    )
    .order_by(
        func.ts_rank(
            Translation.content_tsv,
            func.websearch_to_tsquery(ts_config, q)
        ).desc()
    )
    .limit(limit)
)

websearch_to_tsquery 与 tsquery 家族:PostgreSQL 提供多个把原始查询字符串转换为 tsquery 的函数。to_tsquery 要求严格语法,plainto_tsquery 把整段文本当成 AND 词项,而 websearch_to_tsquery 模仿 Google 的写法——支持双引号短语、OR 连接以及 - 排除,且对畸形输入容忍度高,因此更适合直接暴露给终端用户。与之配套的还有 ts_headline 负责生成高亮摘要、ts_rank(及更快的 ts_rank_cd)负责相关度打分。把它们组合进一条 SQL,意味着用户请求只需一次数据库往返就能完成过滤、排序与摘要生成,避免了在应用层拉回全量文本再处理的开销。

模糊书名搜索

针对书名拼写错误,团队启用 pg_trgm 扩展,在 books.title 上建立 gin_trgm_ops 索引,查询时用 similarity(Book.title, q) > 0.3 过滤并按相似度排序。

pg_trgm 与三元组相似度:pg_trgm 是 PostgreSQL 的官方扩展,它把字符串拆成连续三个字符组成的“三元组”(trigram),例如 “book” 会生成 “ b”、“ bo”、“boo”、“ook”、“ok ” 等多个单元。通过比较两个字符串共有的三元组比例,就能计算出 0 到 1 之间的 similarity 分数,从而容忍错别字、漏字或词序颠倒。配合 gin_trgm_ops 操作符类建立 GIN 索引后,相似度查找不必逐行计算,这也是书名模糊搜索能从 1.2s 降到 15ms 的关键。它常与全文搜索互补:前者负责“拼写接近”,后者负责“语义相关”。

性能表现与取舍

优化前后的对比数据非常直观。在加入 GIN 索引和生成式 tsvector 之前,跨 10000 条翻译的搜索需要约 800ms,因为数据库不得不现场扫描并转换文本。加索引后,即使数据量涨到 100000 条翻译,同样查询降到约 20-40ms。书名模糊搜索从 1.2s 降到 15ms。

最大的取舍是放弃 Elasticsearch。在当前规模下,PostgreSQL 运维更简单、无需额外服务、与关系数据天然集成。但团队也清楚,如果增长到百万级文档或需要分面搜索等高级功能,可能仍需重新考虑 Elasticsearch。

另一个尚未解决的取舍是语言配置。PostgreSQL 开箱支持多种语言,但中文、日文等 CJK 语言需要不同的分词方式(如 pg_jieba 或 zhparser),目前只能回退到按空格切分的 simple 配置,效果并不理想。

CJK 分词难题:PostgreSQL 内置的文本搜索配置默认按空格和标点切词,这对英语、法语等语言行之有效,但中文、日文、韩文没有空格分隔,整段文字可能被当成单一词元,导致召回率极低。simple 配置只做小写化、不做词干还原,因此并非真正的替代方案。社区常见的解决路径是安装分词扩展:zhparser 基于 SCWS 做中文分词,pg_jieba 封装结巴分词,两者都能注册为自定义文本搜索配置,进而与 tsvector、GIN 索引和 ts_rank 无缝协作。它们需要通过编译扩展或使用预构建镜像来部署,这也是团队将其列为开放问题的原因。

经验总结与开放问题

LectuLibre 团队给出了几条实战建议。先榨干 PostgreSQL 的内置能力再考虑引入新基础设施,能显著简化部署。用生成列和触发器同步搜索向量,避免在应用层手动维护索引。FastAPI 配异步 SQLAlchemy 工作良好,但要注意会话管理,他们使用 async_sessionmaker 加依赖注入确保每个请求后会话关闭。务必用真实数据量测试,1000 行时表现正常的查询在数据增长后迅速劣化,加索引和生成列是直接的修复方案。

团队最后抛出一个开放问题:对于 PostgreSQL 原生不支持的语言(尤其是 CJK),社区是如何处理全文搜索的?他们正在考虑为中文集成 pg_jieba 分词器,欢迎有经验的开发者分享替代方案。

#FastAPI PostgreSQL#多语言全文搜索#PostgreSQL FTS#tsvector GIN 索引#pg_trgm 模糊搜索#SQLAlchemy 异步#图书翻译平台架构
分享:

相关推荐