[{"data":1,"prerenderedAt":1440},["ShallowReactive",2],{"\u002Farticles\u002Fes-postgresql":3},{"id":4,"title":5,"body":6,"category":1417,"cover":16,"date":1418,"description":1419,"extension":1420,"meta":1421,"navigation":213,"path":1430,"seo":1431,"sitemap":1432,"stem":1438,"__hash__":1439},"articles\u002Farticles\u002F先别急着上 ES：一次 PostgreSQL 中文商品全文搜索的实测记录.md","先别急着上 ES：一次 PostgreSQL 中文商品全文搜索的实测记录",{"type":7,"value":8,"toc":1401},"minimark",[9,17,20,23,28,39,102,116,124,267,273,276,355,365,369,374,377,543,546,561,568,572,583,735,756,766,770,776,783,786,816,825,828,881,889,896,899,903,907,910,916,919,928,931,934,938,945,970,976,983,986,990,1003,1009,1016,1024,1028,1031,1132,1136,1139,1142,1204,1228,1231,1270,1279,1282,1316,1320,1327,1388,1391,1394,1397],[10,11,12],"p",{},[13,14],"img",{"alt":15,"src":16},"文章配图 1","\u002Fimg\u002Farticles\u002Fpostgresql-chinese-search\u002F01.png",[10,18,19],{},"做商品搜索时，我首先想到的也是 Elasticsearch。不过，在增加一套搜索服务之前，我想先验证一个问题：如果商品数据已经存放在 PostgreSQL 中，需求主要是中文关键词检索、业务筛选和基础相关性排序，数据库现有的能力能否满足要求？",[10,21,22],{},"这次实验使用 PostgreSQL 18.6 + zhparser 2.4 + pg_trgm 1.6；",[24,25,27],"h2",{"id":26},"_1-先确认各组件负责什么","1. 先确认各组件负责什么",[10,29,30,31,38],{},"常被口头称为“pg_zhparser”的扩展，实际扩展名是 ",[32,33,34],"strong",{},[35,36,37],"code",{},"zhparser","。",[40,41,42,58],"table",{},[43,44,45],"thead",{},[46,47,48,52,55],"tr",{},[49,50,51],"th",{},"组件",[49,53,54],{},"本次用途",[49,56,57],{},"能力边界",[59,60,61,72,91],"tbody",{},[46,62,63,66,69],{},[64,65,37],"td",{},[64,67,68],{},"借助 SCWS 对中文文本分词",[64,70,71],{},"不负责索引维护和相关性排名",[46,73,74,77,88],{},[64,75,76],{},"PostgreSQL 内置全文检索",[64,78,79,80,83,84,87],{},"使用 ",[35,81,82],{},"tsvector"," 表示词项及其位置、权重；通过 GIN 加速匹配，再用 ",[35,85,86],{},"ts_rank_cd"," 排名",[64,89,90],{},"不会自动理解商品意图或完成业务同义词扩展",[46,92,93,96,99],{},[64,94,95],{},"pg_trgm",[64,97,98],{},"基于字符三元组，支持字符串相似度和部分模式匹配查询的索引加速",[64,100,101],{},"不是中文分词器，也不是语义模型",[10,103,104,105,108,109,38,112,115],{},"安装需要区分两件事：",[32,106,107],{},"服务器已经具备扩展文件","，以及",[32,110,111],{},"当前数据库已经启用扩展",[35,113,114],{},"CREATE EXTENSION"," 不会自动下载和编译扩展。",[10,117,118,120,121,123],{},[35,119,95],{}," 通常由 PostgreSQL 的 contrib 模块提供；缺少文件时，需要安装与发行版、PostgreSQL 大版本相匹配的软件包。",[35,122,37],{}," 是第三方扩展，一般需要先安装 SCWS，再进行编译。下面仅展示 Linux 下的构建流程，前提是已经准备好编译工具、PG 18 开发头文件和 SCWS 1.2.3 源码包：",[125,126,131],"pre",{"className":127,"code":128,"language":129,"meta":130,"style":130},"language-bash shiki shiki-themes github-dark","# 从 SCWS 官方站获取 scws-1.2.3.tar.bz2 后执行。\ntar -xjf scws-1.2.3.tar.bz2\n(cd scws-1.2.3 && .\u002Fconfigure --prefix=\u002Fusr\u002Flocal --with-pic \\\n  && make && sudo make install)\n\ngit clone https:\u002F\u002Fgithub.com\u002Famutu\u002Fzhparser.git\ncd zhparser\n# 编译前切换到自己验证过的 tag 或 commit；默认分支不保证对应本文版本。\nmake PG_CONFIG=\u002Fusr\u002Flib\u002Fpostgresql\u002F18\u002Fbin\u002Fpg_config SCWS_HOME=\u002Fusr\u002Flocal\nsudo make PG_CONFIG=\u002Fusr\u002Flib\u002Fpostgresql\u002F18\u002Fbin\u002Fpg_config SCWS_HOME=\u002Fusr\u002Flocal install\n","bash","",[35,132,133,142,157,185,208,215,227,235,241,252],{"__ignoreMap":130},[134,135,138],"span",{"class":136,"line":137},"line",1,[134,139,141],{"class":140},"sAwPA","# 从 SCWS 官方站获取 scws-1.2.3.tar.bz2 后执行。\n",[134,143,145,149,153],{"class":136,"line":144},2,[134,146,148],{"class":147},"svObZ","tar",[134,150,152],{"class":151},"sDLfK"," -xjf",[134,154,156],{"class":155},"sU2Wk"," scws-1.2.3.tar.bz2\n",[134,158,160,164,167,170,173,176,179,182],{"class":136,"line":159},3,[134,161,163],{"class":162},"s95oV","(",[134,165,166],{"class":151},"cd",[134,168,169],{"class":155}," scws-1.2.3",[134,171,172],{"class":162}," && ",[134,174,175],{"class":147},".\u002Fconfigure",[134,177,178],{"class":151}," --prefix=\u002Fusr\u002Flocal",[134,180,181],{"class":151}," --with-pic",[134,183,184],{"class":151}," \\\n",[134,186,188,191,194,196,199,202,205],{"class":136,"line":187},4,[134,189,190],{"class":162},"  && ",[134,192,193],{"class":147},"make",[134,195,172],{"class":162},[134,197,198],{"class":147},"sudo",[134,200,201],{"class":155}," make",[134,203,204],{"class":155}," install",[134,206,207],{"class":162},")\n",[134,209,211],{"class":136,"line":210},5,[134,212,214],{"emptyLinePlaceholder":213},true,"\n",[134,216,218,221,224],{"class":136,"line":217},6,[134,219,220],{"class":147},"git",[134,222,223],{"class":155}," clone",[134,225,226],{"class":155}," https:\u002F\u002Fgithub.com\u002Famutu\u002Fzhparser.git\n",[134,228,230,232],{"class":136,"line":229},7,[134,231,166],{"class":151},[134,233,234],{"class":155}," zhparser\n",[134,236,238],{"class":136,"line":237},8,[134,239,240],{"class":140},"# 编译前切换到自己验证过的 tag 或 commit；默认分支不保证对应本文版本。\n",[134,242,244,246,249],{"class":136,"line":243},9,[134,245,193],{"class":147},[134,247,248],{"class":155}," PG_CONFIG=\u002Fusr\u002Flib\u002Fpostgresql\u002F18\u002Fbin\u002Fpg_config",[134,250,251],{"class":155}," SCWS_HOME=\u002Fusr\u002Flocal\n",[134,253,255,257,259,261,264],{"class":136,"line":254},10,[134,256,198],{"class":147},[134,258,201],{"class":155},[134,260,248],{"class":155},[134,262,263],{"class":155}," SCWS_HOME=\u002Fusr\u002Flocal",[134,265,266],{"class":155}," install\n",[10,268,269,272],{},[35,270,271],{},"pg_config"," 路径需要根据实际环境调整。使用 Docker 时，应将安装步骤固化到镜像中，避免容器重建后丢失扩展。托管数据库需要先确认扩展白名单；Windows 原生部署则需要匹配版本的二进制文件，不能直接照搬上述 Linux 命令。",[10,274,275],{},"我这次使用 Docker 搭建本地环境，并借助 AI 编程工具辅助安装和排查问题。工具可以减少重复操作，但扩展版本、安装日志和最终 SQL 结果仍然需要核对。",[125,277,281],{"className":278,"code":279,"language":280,"meta":130,"style":130},"language-sql shiki shiki-themes github-dark","-- 查看服务器可用的扩展。\n-- installed_version 为 NULL，表示当前数据库尚未启用该扩展。\nSELECT name, default_version, installed_version\nFROM pg_available_extensions\nWHERE name IN ('zhparser', 'pg_trgm');\n\nCREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public;\nCREATE EXTENSION IF NOT EXISTS zhparser WITH SCHEMA public;\n\n-- 核对已安装扩展的版本与所在 schema。\nSELECT e.extname, e.extversion, n.nspname AS extension_schema\nFROM pg_extension e\nJOIN pg_namespace n ON n.oid = e.extnamespace\nWHERE e.extname IN ('zhparser', 'pg_trgm');\n","sql",[35,282,283,288,293,298,303,308,312,317,322,326,331,337,343,349],{"__ignoreMap":130},[134,284,285],{"class":136,"line":137},[134,286,287],{},"-- 查看服务器可用的扩展。\n",[134,289,290],{"class":136,"line":144},[134,291,292],{},"-- installed_version 为 NULL，表示当前数据库尚未启用该扩展。\n",[134,294,295],{"class":136,"line":159},[134,296,297],{},"SELECT name, default_version, installed_version\n",[134,299,300],{"class":136,"line":187},[134,301,302],{},"FROM pg_available_extensions\n",[134,304,305],{"class":136,"line":210},[134,306,307],{},"WHERE name IN ('zhparser', 'pg_trgm');\n",[134,309,310],{"class":136,"line":217},[134,311,214],{"emptyLinePlaceholder":213},[134,313,314],{"class":136,"line":229},[134,315,316],{},"CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public;\n",[134,318,319],{"class":136,"line":237},[134,320,321],{},"CREATE EXTENSION IF NOT EXISTS zhparser WITH SCHEMA public;\n",[134,323,324],{"class":136,"line":243},[134,325,214],{"emptyLinePlaceholder":213},[134,327,328],{"class":136,"line":254},[134,329,330],{},"-- 核对已安装扩展的版本与所在 schema。\n",[134,332,334],{"class":136,"line":333},11,[134,335,336],{},"SELECT e.extname, e.extversion, n.nspname AS extension_schema\n",[134,338,340],{"class":136,"line":339},12,[134,341,342],{},"FROM pg_extension e\n",[134,344,346],{"class":136,"line":345},13,[134,347,348],{},"JOIN pg_namespace n ON n.oid = e.extnamespace\n",[134,350,352],{"class":136,"line":351},14,[134,353,354],{},"WHERE e.extname IN ('zhparser', 'pg_trgm');\n",[10,356,357,358,38,361,364],{},"下文假设扩展位于 ",[35,359,360],{},"public",[35,362,363],{},"IF NOT EXISTS"," 不会把已安装的扩展移动到指定 schema；如果实际位置不同，需要调整限定名。",[24,366,368],{"id":367},"_2-从单表开始生成并维护搜索向量","2. 从单表开始：生成并维护搜索向量",[370,371,373],"h3",{"id":372},"建表并配置中文检索","① 建表并配置中文检索",[10,375,376],{},"先创建独立的实验 schema 和商品表，再定义中文检索配置：",[125,378,380],{"className":278,"code":379,"language":280,"meta":130,"style":130},"CREATE SCHEMA search_single;\nSET search_path = search_single, public;\n\nCREATE TABLE search_single.products (\n    id BIGSERIAL PRIMARY KEY,\n    title VARCHAR(200) NOT NULL,\n    subtitle VARCHAR(500),\n    description TEXT,\n    brand VARCHAR(100),\n    is_active BOOLEAN DEFAULT TRUE,\n    created_at TIMESTAMPTZ DEFAULT NOW(),\n    search_vector tsvector\n);\n\nCREATE TEXT SEARCH CONFIGURATION search_single.chinese_zh\n    (PARSER = public.zhparser);\n\n-- 将以下词性映射到 simple 字典：\n-- n：名词；v：动词；a：形容词；i：成语；e：叹词；l：习用语；x：未知词性。\nALTER TEXT SEARCH CONFIGURATION search_single.chinese_zh\n    ADDMAPPINGFOR n, v, a, i, e, l, x WITH pg_catalog.simple;\n\n-- 固定本次复现实验的复合分词选项，避免依赖连接原有设置。\nSET zhparser.multi_short = off;\nSET zhparser.multi_duality = off;\nSET zhparser.multi_zmain = off;\nSET zhparser.multi_zall = off;\n\nSELECT *\nFROM ts_debug('search_single.chinese_zh', '苹果 降噪 AirPods');\n",[35,381,382,387,392,396,401,406,411,416,421,426,431,436,441,446,450,456,462,467,473,479,485,491,496,502,508,514,520,526,531,537],{"__ignoreMap":130},[134,383,384],{"class":136,"line":137},[134,385,386],{},"CREATE SCHEMA search_single;\n",[134,388,389],{"class":136,"line":144},[134,390,391],{},"SET search_path = search_single, public;\n",[134,393,394],{"class":136,"line":159},[134,395,214],{"emptyLinePlaceholder":213},[134,397,398],{"class":136,"line":187},[134,399,400],{},"CREATE TABLE search_single.products (\n",[134,402,403],{"class":136,"line":210},[134,404,405],{},"    id BIGSERIAL PRIMARY KEY,\n",[134,407,408],{"class":136,"line":217},[134,409,410],{},"    title VARCHAR(200) NOT NULL,\n",[134,412,413],{"class":136,"line":229},[134,414,415],{},"    subtitle VARCHAR(500),\n",[134,417,418],{"class":136,"line":237},[134,419,420],{},"    description TEXT,\n",[134,422,423],{"class":136,"line":243},[134,424,425],{},"    brand VARCHAR(100),\n",[134,427,428],{"class":136,"line":254},[134,429,430],{},"    is_active BOOLEAN DEFAULT TRUE,\n",[134,432,433],{"class":136,"line":333},[134,434,435],{},"    created_at TIMESTAMPTZ DEFAULT NOW(),\n",[134,437,438],{"class":136,"line":339},[134,439,440],{},"    search_vector tsvector\n",[134,442,443],{"class":136,"line":345},[134,444,445],{},");\n",[134,447,448],{"class":136,"line":351},[134,449,214],{"emptyLinePlaceholder":213},[134,451,453],{"class":136,"line":452},15,[134,454,455],{},"CREATE TEXT SEARCH CONFIGURATION search_single.chinese_zh\n",[134,457,459],{"class":136,"line":458},16,[134,460,461],{},"    (PARSER = public.zhparser);\n",[134,463,465],{"class":136,"line":464},17,[134,466,214],{"emptyLinePlaceholder":213},[134,468,470],{"class":136,"line":469},18,[134,471,472],{},"-- 将以下词性映射到 simple 字典：\n",[134,474,476],{"class":136,"line":475},19,[134,477,478],{},"-- n：名词；v：动词；a：形容词；i：成语；e：叹词；l：习用语；x：未知词性。\n",[134,480,482],{"class":136,"line":481},20,[134,483,484],{},"ALTER TEXT SEARCH CONFIGURATION search_single.chinese_zh\n",[134,486,488],{"class":136,"line":487},21,[134,489,490],{},"    ADDMAPPINGFOR n, v, a, i, e, l, x WITH pg_catalog.simple;\n",[134,492,494],{"class":136,"line":493},22,[134,495,214],{"emptyLinePlaceholder":213},[134,497,499],{"class":136,"line":498},23,[134,500,501],{},"-- 固定本次复现实验的复合分词选项，避免依赖连接原有设置。\n",[134,503,505],{"class":136,"line":504},24,[134,506,507],{},"SET zhparser.multi_short = off;\n",[134,509,511],{"class":136,"line":510},25,[134,512,513],{},"SET zhparser.multi_duality = off;\n",[134,515,517],{"class":136,"line":516},26,[134,518,519],{},"SET zhparser.multi_zmain = off;\n",[134,521,523],{"class":136,"line":522},27,[134,524,525],{},"SET zhparser.multi_zall = off;\n",[134,527,529],{"class":136,"line":528},28,[134,530,214],{"emptyLinePlaceholder":213},[134,532,534],{"class":136,"line":533},29,[134,535,536],{},"SELECT *\n",[134,538,540],{"class":136,"line":539},30,[134,541,542],{},"FROM ts_debug('search_single.chinese_zh', '苹果 降噪 AirPods');\n",[10,544,545],{},"这里的单表包含标题、副标题、描述和品牌。",[10,547,548,549,552,553,556,557,560],{},"分词器负责识别文本中的词，字典负责处理分词结果。",[35,550,551],{},"simple"," 字典会做小写化等处理，不会自动把“苹果”扩展成 ",[35,554,555],{},"apple","。词性映射也不是越多越好：应通过 ",[35,558,559],{},"ts_debug"," 检查目标词的类型及最终词项，避免词已经切出来，却因为没有映射而未进入向量。",[10,562,563,564,567],{},"这些 ",[35,565,566],{},"SET"," 只影响当前连接。生成测试数据、业务写入、查询和后续重算使用的连接，都应采用一致的分词配置；在 Navicat 中另开连接，也不能假设它会继承前一个连接的设置。",[370,569,571],{"id":570},"用触发器维护向量","② 用触发器维护向量",[10,573,574,575,578,579,582],{},"本次给标题设置 A 权重，副标题和品牌设置 B 权重，描述设置 C 权重。",[35,576,577],{},"search_vector"," 保存的是供全文检索使用的词项、位置和权重信息，",[32,580,581],{},"字段本身不是索引","；GIN 索引需要另外创建。",[125,584,586],{"className":278,"code":585,"language":280,"meta":130,"style":130},"CREATE OR REPLACE FUNCTION search_single.update_product_search_vector()\nRETURNS trigger AS $$\nBEGIN\n    -- 只有文本字段均未改变，且向量已经存在时，才跳过重算。\n    -- 保留 NULL 向量的补算入口，便于回填触发器创建前导入的数据。\n    IF TG_OP = 'UPDATE'THEN\n        IF NEW.title       IS NOT DISTINCT FROM OLD.title\n           AND NEW.subtitle    IS NOT DISTINCT FROM OLD.subtitle\n           AND NEW.brand       IS NOT DISTINCT FROM OLD.brand\n           AND NEW.description IS NOT DISTINCT FROM OLD.description\n           AND NEW.search_vector IS NOT NULL\n        THEN\n            RETURN NEW;\n        END IF;\n    END IF;\n\n    NEW.search_vector :=\n        setweight(to_tsvector('search_single.chinese_zh', coalesce(NEW.title, '')), 'A') ||\n        setweight(to_tsvector('search_single.chinese_zh', coalesce(NEW.subtitle, '')), 'B') ||\n        setweight(to_tsvector('search_single.chinese_zh', coalesce(NEW.brand, '')), 'B') ||\n        setweight(to_tsvector('search_single.chinese_zh', coalesce(NEW.description, '')), 'C');\n\n    RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\nCREATE TRIGGER trg_product_search_vector\nBEFORE INSERT OR UPDATE OF title, subtitle, brand, description\nON search_single.products\nFOR EACH ROW EXECUTE FUNCTION search_single.update_product_search_vector();\n",[35,587,588,593,598,603,608,613,618,623,628,633,638,643,648,653,658,663,667,672,677,682,687,692,696,701,706,711,715,720,725,730],{"__ignoreMap":130},[134,589,590],{"class":136,"line":137},[134,591,592],{},"CREATE OR REPLACE FUNCTION search_single.update_product_search_vector()\n",[134,594,595],{"class":136,"line":144},[134,596,597],{},"RETURNS trigger AS $$\n",[134,599,600],{"class":136,"line":159},[134,601,602],{},"BEGIN\n",[134,604,605],{"class":136,"line":187},[134,606,607],{},"    -- 只有文本字段均未改变，且向量已经存在时，才跳过重算。\n",[134,609,610],{"class":136,"line":210},[134,611,612],{},"    -- 保留 NULL 向量的补算入口，便于回填触发器创建前导入的数据。\n",[134,614,615],{"class":136,"line":217},[134,616,617],{},"    IF TG_OP = 'UPDATE'THEN\n",[134,619,620],{"class":136,"line":229},[134,621,622],{},"        IF NEW.title       IS NOT DISTINCT FROM OLD.title\n",[134,624,625],{"class":136,"line":237},[134,626,627],{},"           AND NEW.subtitle    IS NOT DISTINCT FROM OLD.subtitle\n",[134,629,630],{"class":136,"line":243},[134,631,632],{},"           AND NEW.brand       IS NOT DISTINCT FROM OLD.brand\n",[134,634,635],{"class":136,"line":254},[134,636,637],{},"           AND NEW.description IS NOT DISTINCT FROM OLD.description\n",[134,639,640],{"class":136,"line":333},[134,641,642],{},"           AND NEW.search_vector IS NOT NULL\n",[134,644,645],{"class":136,"line":339},[134,646,647],{},"        THEN\n",[134,649,650],{"class":136,"line":345},[134,651,652],{},"            RETURN NEW;\n",[134,654,655],{"class":136,"line":351},[134,656,657],{},"        END IF;\n",[134,659,660],{"class":136,"line":452},[134,661,662],{},"    END IF;\n",[134,664,665],{"class":136,"line":458},[134,666,214],{"emptyLinePlaceholder":213},[134,668,669],{"class":136,"line":464},[134,670,671],{},"    NEW.search_vector :=\n",[134,673,674],{"class":136,"line":469},[134,675,676],{},"        setweight(to_tsvector('search_single.chinese_zh', coalesce(NEW.title, '')), 'A') ||\n",[134,678,679],{"class":136,"line":475},[134,680,681],{},"        setweight(to_tsvector('search_single.chinese_zh', coalesce(NEW.subtitle, '')), 'B') ||\n",[134,683,684],{"class":136,"line":481},[134,685,686],{},"        setweight(to_tsvector('search_single.chinese_zh', coalesce(NEW.brand, '')), 'B') ||\n",[134,688,689],{"class":136,"line":487},[134,690,691],{},"        setweight(to_tsvector('search_single.chinese_zh', coalesce(NEW.description, '')), 'C');\n",[134,693,694],{"class":136,"line":493},[134,695,214],{"emptyLinePlaceholder":213},[134,697,698],{"class":136,"line":498},[134,699,700],{},"    RETURN NEW;\n",[134,702,703],{"class":136,"line":504},[134,704,705],{},"END;\n",[134,707,708],{"class":136,"line":510},[134,709,710],{},"$$ LANGUAGE plpgsql;\n",[134,712,713],{"class":136,"line":516},[134,714,214],{"emptyLinePlaceholder":213},[134,716,717],{"class":136,"line":522},[134,718,719],{},"CREATE TRIGGER trg_product_search_vector\n",[134,721,722],{"class":136,"line":528},[134,723,724],{},"BEFORE INSERT OR UPDATE OF title, subtitle, brand, description\n",[134,726,727],{"class":136,"line":533},[134,728,729],{},"ON search_single.products\n",[134,731,732],{"class":136,"line":539},[134,733,734],{},"FOR EACH ROW EXECUTE FUNCTION search_single.update_product_search_vector();\n",[10,736,737,740,741,744,745,748,749,751,752,755],{},[35,738,739],{},"IS NOT DISTINCT FROM"," 用于正确比较可能为 ",[35,742,743],{},"NULL"," 的字段。还需要区分“进入触发器”和“真正重算向量”：",[35,746,747],{},"UPDATE OF"," 判断的是字段是否出现在 ",[35,750,566],{}," 列表中，即使执行 ",[35,753,754],{},"SET title = title","，也会进入触发器；函数内部再判断是否有必要重算。",[10,757,758,759,762,763,765],{},"实验调试时可以临时加入 ",[35,760,761],{},"RAISE NOTICE '触发器执行了：表=%, 操作=%, 行ID=%', TG_TABLE_NAME, TG_OP, NEW.id;","，观察触发情况，但不应在几百万行导入或回填时逐行输出消息，否则会干扰测量并增加额外开销。应用也不应直接维护 ",[35,764,577],{},"，而应让它由源文本派生。",[370,767,769],{"id":768},"已有数据需要单独回填","③ 已有数据需要单独回填",[10,771,772,773],{},"这里容易遗漏一个步骤：",[32,774,775],{},"创建触发器不会自动处理表中已有的数据。",[10,777,778,779,782],{},"如果先创建触发器，再通过普通 ",[35,780,781],{},"INSERT"," 导入数据，向量会在插入时生成；如果先用 Navicat 生成了数据，随后才创建触发器，就需要补算已有记录。还应确认导入方式没有禁用触发器。",[10,784,785],{},"下面只演示一个主键区间，不是对全表的完整回填：",[125,787,789],{"className":278,"code":788,"language":280,"meta":130,"style":130},"-- 示例批次：按实际主键范围逐批推进，并逐批提交。\nUPDATE search_single.products\nSET title = title\nWHERE id > 0 AND id \u003C= 10000\n  AND search_vector IS NULL;\n",[35,790,791,796,801,806,811],{"__ignoreMap":130},[134,792,793],{"class":136,"line":137},[134,794,795],{},"-- 示例批次：按实际主键范围逐批推进，并逐批提交。\n",[134,797,798],{"class":136,"line":144},[134,799,800],{},"UPDATE search_single.products\n",[134,802,803],{"class":136,"line":159},[134,804,805],{},"SET title = title\n",[134,807,808],{"class":136,"line":187},[134,809,810],{},"WHERE id > 0 AND id \u003C= 10000\n",[134,812,813],{"class":136,"line":210},[134,814,815],{},"  AND search_vector IS NULL;\n",[10,817,818,819,821,822,824],{},"这条语句会进入触发器。因为待处理记录的向量为 ",[35,820,743],{},"，不会被函数中的短路判断跳过。若仅判断“文本字段有没有变化”，",[35,823,754],{}," 就无法完成回填。",[10,826,827],{},"批量处理完成后，再核对实际数据量和向量覆盖情况：",[125,829,831],{"className":278,"code":830,"language":280,"meta":130,"style":130},"-- 大表上的精确计数也有成本，不要在每个批次结束后都全表执行。\nSELECT count(*) AS total_rows,\n       count(*) FILTER (WHERE search_vector IS NULL) AS missing_vectors,\n       count(*) FILTER (WHERE search_vector = ''::tsvector) AS empty_vectors\nFROM search_single.products;\n\nCREATE INDEX idx_products_search_zh\nON search_single.products USING GIN (search_vector);\n\nANALYZE search_single.products;\n",[35,832,833,838,843,848,853,858,862,867,872,876],{"__ignoreMap":130},[134,834,835],{"class":136,"line":137},[134,836,837],{},"-- 大表上的精确计数也有成本，不要在每个批次结束后都全表执行。\n",[134,839,840],{"class":136,"line":144},[134,841,842],{},"SELECT count(*) AS total_rows,\n",[134,844,845],{"class":136,"line":159},[134,846,847],{},"       count(*) FILTER (WHERE search_vector IS NULL) AS missing_vectors,\n",[134,849,850],{"class":136,"line":187},[134,851,852],{},"       count(*) FILTER (WHERE search_vector = ''::tsvector) AS empty_vectors\n",[134,854,855],{"class":136,"line":210},[134,856,857],{},"FROM search_single.products;\n",[134,859,860],{"class":136,"line":217},[134,861,214],{"emptyLinePlaceholder":213},[134,863,864],{"class":136,"line":229},[134,865,866],{},"CREATE INDEX idx_products_search_zh\n",[134,868,869],{"class":136,"line":237},[134,870,871],{},"ON search_single.products USING GIN (search_vector);\n",[134,873,874],{"class":136,"line":243},[134,875,214],{"emptyLinePlaceholder":213},[134,877,878],{"class":136,"line":254},[134,879,880],{},"ANALYZE search_single.products;\n",[10,882,883,885,886,888],{},[35,884,743],{}," 向量与空向量不是同一回事。前者表示字段尚未赋值；后者可能是文本为空，或没有词项通过当前配置，需要结合原始内容和 ",[35,887,559],{}," 排查。",[10,890,891,892,895],{},"这里是在实验表完成导入和回填后建索引。已有业务表如何建索引，应另行评估锁等待、磁盘空间和是否需要 ",[35,893,894],{},"CREATE INDEX CONCURRENTLY","，不能直接套用实验环境的操作顺序。",[10,897,898],{},"对后续普通写入，源文本与向量在同一事务中更新；其他连接能否读到新结果，仍受事务提交和隔离级别影响。批量修改还会带来分词、GIN 维护、WAL 和清理成本，不能把“同步维护”理解成没有写入开销。",[24,900,902],{"id":901},"_3-对照截图命中了什么分数说明什么","3. 对照截图：命中了什么，分数说明什么",[370,904,906],{"id":905},"先检查一条记录的向量","① 先检查一条记录的向量",[10,908,909],{},"下面是单表实验中的一条记录：",[10,911,912],{},[13,913],{"alt":914,"src":915},"文章配图 2","\u002Fimg\u002Farticles\u002Fpostgresql-chinese-search\u002F02.png",[10,917,918],{},"与本次查询有关的内容是：副标题包含“保健，家庭及婴儿护理”，向量中可以看到：",[125,920,922],{"className":278,"code":921,"language":280,"meta":130,"style":130},"'婴儿':4B '护理':5B\n",[35,923,924],{"__ignoreMap":130},[134,925,926],{"class":136,"line":137},[134,927,921],{},[10,929,930],{},"这表示“婴儿”和“护理”已进入向量，位置分别为 4、5，权重标签都是 B。它们出现在副标题中，因此与触发器里设置的 B 权重一致。",[10,932,933],{},"这张截图可以用于检查该记录的向量内容。",[370,935,937],{"id":936},"区分-and-查询与短语查询","② 区分 AND 查询与短语查询",[10,939,940,941,944],{},"查询前先打印实际生成的 ",[35,942,943],{},"tsquery","，不要只根据输入文字判断查询语义：",[125,946,948],{"className":278,"code":947,"language":280,"meta":130,"style":130},"SELECT\n    websearch_to_tsquery('search_single.chinese_zh', '婴儿 护理') AS and_query,\n    websearch_to_tsquery('search_single.chinese_zh', '\"婴儿 护理\"') AS phrase_query,\n    websearch_to_tsquery('search_single.chinese_zh', '婴儿护理') AS unspaced_query;\n",[35,949,950,955,960,965],{"__ignoreMap":130},[134,951,952],{"class":136,"line":137},[134,953,954],{},"SELECT\n",[134,956,957],{"class":136,"line":144},[134,958,959],{},"    websearch_to_tsquery('search_single.chinese_zh', '婴儿 护理') AS and_query,\n",[134,961,962],{"class":136,"line":159},[134,963,964],{},"    websearch_to_tsquery('search_single.chinese_zh', '\"婴儿 护理\"') AS phrase_query,\n",[134,966,967],{"class":136,"line":187},[134,968,969],{},"    websearch_to_tsquery('search_single.chinese_zh', '婴儿护理') AS unspaced_query;\n",[10,971,972],{},[13,973],{"alt":974,"src":975},"文章配图 3","\u002Fimg\u002Farticles\u002Fpostgresql-chinese-search\u002F03.png",[10,977,978,979,982],{},"在“婴儿”“护理”都被保留为独立词项的前提下，未加引号的输入生成 AND 查询，要求两个词都出现，但不要求相邻；双引号输入生成短语查询，要求满足分词后的位置关系。本例没有中间停用词时，短语查询会使用 ",[35,980,981],{},"\u003C->","，即相邻位置。",[10,984,985],{},"“婴儿护理”这个无空格输入最终生成什么词项，应以当前分词器、词库和配置的实际输出为准。",[370,987,989],{"id":988},"如何理解截图中的-04","③ 如何理解截图中的 0.4",[10,991,992,994,995,998,999,1002],{},[35,993,86],{}," 默认的 A\u002FB\u002FC\u002FD 权重分别为 ",[32,996,997],{},"1.0 \u002F 0.4 \u002F 0.2 \u002F 0.1","。如果显式传入权重数组，参数顺序则是 ",[32,1000,1001],{},"D、C、B、A","，不要写反。",[10,1004,1005],{},[13,1006],{"alt":1007,"src":1008},"文章配图 4","\u002Fimg\u002Farticles\u002Fpostgresql-chinese-search\u002F04.png",[10,1010,1011,1012,1015],{},"截图中，命中的“婴儿”和“护理”位置相邻，且均为 B 权重；显示的排名分数为 ",[35,1013,1014],{},"0.4","。这一结果与该记录的命中情况相符，但不能概括成“只要命中 B 权重，分数就是 0.4”。",[10,1017,1018,1020,1021,1023],{},[35,1019,86],{}," 使用覆盖密度排名，会考虑命中词项的位置、接近程度等信息；出现次数、权重和归一化参数也会影响结果。A 权重更高，并不意味着任何标题命中都一定排在所有其他结果之前。这个分数也不是概率，",[35,1022,1014],{}," 不代表“40% 相关”。",[24,1025,1027],{"id":1026},"_4-用-pg_trgm-补充近似匹配","4. 用 pg_trgm 补充近似匹配",[10,1029,1030],{},"全文检索与字符串匹配处理的是不同问题。对于型号包含关系，以及接近完整标题的拼写错误，可以另外验证 pg_trgm：",[125,1032,1034],{"className":278,"code":1033,"language":280,"meta":130,"style":130},"-- 可选索引：先在实验环境测量体积和收益，再决定是否保留。\nCREATE INDEX idx_products_title_trgm\nON search_single.products USING GIN (title public.gin_trgm_ops);\n\n-- 型号子串匹配；返回记录取决于当前数据是否包含该型号。\nSELECT id, title\nFROM search_single.products\nWHERE title ILIKE'%AirPods%'\nAND is_active = TRUE\nORDER BY id\nLIMIT 20;\n\n-- 故意把 AirPods 写成 AirPdos，观察候选与分数。\nSELECT id, title,\n       public.similarity(title, '苹果 AirPdos Pro 2 蓝牙耳机') AS score\nFROM search_single.products\nWHERE title % '苹果 AirPdos Pro 2 蓝牙耳机'\nAND is_active = TRUE\nORDER BY score DESC, id\nLIMIT10;\n",[35,1035,1036,1041,1046,1051,1055,1060,1065,1070,1075,1080,1085,1090,1094,1099,1104,1109,1113,1118,1122,1127],{"__ignoreMap":130},[134,1037,1038],{"class":136,"line":137},[134,1039,1040],{},"-- 可选索引：先在实验环境测量体积和收益，再决定是否保留。\n",[134,1042,1043],{"class":136,"line":144},[134,1044,1045],{},"CREATE INDEX idx_products_title_trgm\n",[134,1047,1048],{"class":136,"line":159},[134,1049,1050],{},"ON search_single.products USING GIN (title public.gin_trgm_ops);\n",[134,1052,1053],{"class":136,"line":187},[134,1054,214],{"emptyLinePlaceholder":213},[134,1056,1057],{"class":136,"line":210},[134,1058,1059],{},"-- 型号子串匹配；返回记录取决于当前数据是否包含该型号。\n",[134,1061,1062],{"class":136,"line":217},[134,1063,1064],{},"SELECT id, title\n",[134,1066,1067],{"class":136,"line":229},[134,1068,1069],{},"FROM search_single.products\n",[134,1071,1072],{"class":136,"line":237},[134,1073,1074],{},"WHERE title ILIKE'%AirPods%'\n",[134,1076,1077],{"class":136,"line":243},[134,1078,1079],{},"AND is_active = TRUE\n",[134,1081,1082],{"class":136,"line":254},[134,1083,1084],{},"ORDER BY id\n",[134,1086,1087],{"class":136,"line":333},[134,1088,1089],{},"LIMIT 20;\n",[134,1091,1092],{"class":136,"line":339},[134,1093,214],{"emptyLinePlaceholder":213},[134,1095,1096],{"class":136,"line":345},[134,1097,1098],{},"-- 故意把 AirPods 写成 AirPdos，观察候选与分数。\n",[134,1100,1101],{"class":136,"line":351},[134,1102,1103],{},"SELECT id, title,\n",[134,1105,1106],{"class":136,"line":452},[134,1107,1108],{},"       public.similarity(title, '苹果 AirPdos Pro 2 蓝牙耳机') AS score\n",[134,1110,1111],{"class":136,"line":458},[134,1112,1069],{},[134,1114,1115],{"class":136,"line":464},[134,1116,1117],{},"WHERE title % '苹果 AirPdos Pro 2 蓝牙耳机'\n",[134,1119,1120],{"class":136,"line":469},[134,1121,1079],{},[134,1123,1124],{"class":136,"line":475},[134,1125,1126],{},"ORDER BY score DESC, id\n",[134,1128,1129],{"class":136,"line":481},[134,1130,1131],{},"LIMIT10;\n",[24,1133,1135],{"id":1134},"_5-自定义词典","5. 自定义词典",[10,1137,1138],{},"如果业务品牌被拆成了不合适的词项，可以进一步尝试自定义词典。例如先检查虚构品牌“声谷”的分词结果，再决定是否加入业务词。不要仅凭品牌名字推断默认分词一定正确或一定错误。",[10,1140,1141],{},"下面依据 zhparser 2.4 的自定义词表与同步函数整理。",[125,1143,1145],{"className":278,"code":1144,"language":280,"meta":130,"style":130},"INSERT INTO zhparser.zhprs_custom_word(word, tf, attr)\nVALUES ('声谷', 100, '@')\nON CONFLICT (word)\nDO UPDATE SET tf = EXCLUDED.tf, attr = EXCLUDED.attr;\n\n-- 可选：验证停用处理。不要直接套用到生产商品语料。\nINSERT INTO zhparser.zhprs_custom_word(word, attr)\nVALUES ('进行', '!')\nON CONFLICT (word)\nDO UPDATE SETattr = EXCLUDED.attr;\n\nSELECT public.sync_zhprs_custom_word();\n",[35,1146,1147,1152,1157,1162,1167,1171,1176,1181,1186,1190,1195,1199],{"__ignoreMap":130},[134,1148,1149],{"class":136,"line":137},[134,1150,1151],{},"INSERT INTO zhparser.zhprs_custom_word(word, tf, attr)\n",[134,1153,1154],{"class":136,"line":144},[134,1155,1156],{},"VALUES ('声谷', 100, '@')\n",[134,1158,1159],{"class":136,"line":159},[134,1160,1161],{},"ON CONFLICT (word)\n",[134,1163,1164],{"class":136,"line":187},[134,1165,1166],{},"DO UPDATE SET tf = EXCLUDED.tf, attr = EXCLUDED.attr;\n",[134,1168,1169],{"class":136,"line":210},[134,1170,214],{"emptyLinePlaceholder":213},[134,1172,1173],{"class":136,"line":217},[134,1174,1175],{},"-- 可选：验证停用处理。不要直接套用到生产商品语料。\n",[134,1177,1178],{"class":136,"line":229},[134,1179,1180],{},"INSERT INTO zhparser.zhprs_custom_word(word, attr)\n",[134,1182,1183],{"class":136,"line":237},[134,1184,1185],{},"VALUES ('进行', '!')\n",[134,1187,1188],{"class":136,"line":243},[134,1189,1161],{},[134,1191,1192],{"class":136,"line":254},[134,1193,1194],{},"DO UPDATE SETattr = EXCLUDED.attr;\n",[134,1196,1197],{"class":136,"line":333},[134,1198,214],{"emptyLinePlaceholder":213},[134,1200,1201],{"class":136,"line":339},[134,1202,1203],{},"SELECT public.sync_zhprs_custom_word();\n",[10,1205,1206,1209,1210,1213,1214,1216,1217,1220,1221,1224,1225,1227],{},[35,1207,1208],{},"@"," 是该自定义词表的默认属性，",[35,1211,1212],{},"!"," 用于停用处理。最终词性、词项及是否进入向量，应通过 ",[35,1215,559],{}," 和 ",[35,1218,1219],{},"to_tsvector"," 确认。",[35,1222,1223],{},"tf"," 是分词词典参数，不能把它理解为 ",[35,1226,86],{}," 的加分权重，也不能仅凭设置了较高值就保证所有模式下都按整词处理。",[10,1229,1230],{},"提交词表变更并成功同步后，可断开并重建连接再验证，避免旧连接状态干扰；连接池应确认更新的是物理连接。新连接中重新设置与写入、查询一致的分词选项：",[125,1232,1234],{"className":278,"code":1233,"language":280,"meta":130,"style":130},"SET zhparser.multi_short = off;\nSET zhparser.multi_duality = off;\nSET zhparser.multi_zmain = off;\nSET zhparser.multi_zall = off;\n\nSELECT *\nFROM ts_debug('search_single.chinese_zh', '声谷 进行 降噪');\nSELECT to_tsvector('search_single.chinese_zh', '声谷 进行 降噪');\n",[35,1235,1236,1240,1244,1248,1252,1256,1260,1265],{"__ignoreMap":130},[134,1237,1238],{"class":136,"line":137},[134,1239,507],{},[134,1241,1242],{"class":136,"line":144},[134,1243,513],{},[134,1245,1246],{"class":136,"line":159},[134,1247,519],{},[134,1249,1250],{"class":136,"line":187},[134,1251,525],{},[134,1253,1254],{"class":136,"line":210},[134,1255,214],{"emptyLinePlaceholder":213},[134,1257,1258],{"class":136,"line":217},[134,1259,536],{},[134,1261,1262],{"class":136,"line":229},[134,1263,1264],{},"FROM ts_debug('search_single.chinese_zh', '声谷 进行 降噪');\n",[134,1266,1267],{"class":136,"line":237},[134,1268,1269],{},"SELECT to_tsvector('search_single.chinese_zh', '声谷 进行 降噪');\n",[10,1271,1272,1273],{},"只有确认新分词符合预期后，才进入已有数据的向量重算阶段。",[32,1274,1275,1276,1278],{},"修改词库不会自动改写已经存储的 ",[35,1277,82],{},"，仅重建 GIN 索引也不能代替重算向量。",[10,1280,1281],{},"对于正文单表，可以按主键范围分批强制重算。例如：",[125,1283,1285],{"className":278,"code":1284,"language":280,"meta":130,"style":130},"-- 仅示范一个批次。源文本不变，同时把新向量置为 NULL，\n-- 让 BEFORE 触发器在同一条 UPDATE 内重新生成向量。\nUPDATE search_single.products\nSET title = title,\n    search_vector = NULL\nWHERE id > 0 AND id \u003C= 10000;\n",[35,1286,1287,1292,1297,1301,1306,1311],{"__ignoreMap":130},[134,1288,1289],{"class":136,"line":137},[134,1290,1291],{},"-- 仅示范一个批次。源文本不变，同时把新向量置为 NULL，\n",[134,1293,1294],{"class":136,"line":144},[134,1295,1296],{},"-- 让 BEFORE 触发器在同一条 UPDATE 内重新生成向量。\n",[134,1298,1299],{"class":136,"line":159},[134,1300,800],{},[134,1302,1303],{"class":136,"line":187},[134,1304,1305],{},"SET title = title,\n",[134,1307,1308],{"class":136,"line":210},[134,1309,1310],{},"    search_vector = NULL\n",[134,1312,1313],{"class":136,"line":217},[134,1314,1315],{},"WHERE id > 0 AND id \u003C= 10000;\n",[24,1317,1319],{"id":1318},"_7-到这里能得出什么结论","7. 到这里能得出什么结论",[10,1321,1322,1323,1326],{},"我的阶段性判断是：",[32,1324,1325],{},"数据已经在 PG 中，且需求主要是关键词检索和结构化筛选时，可以先做一个可测量的原型；是否继续使用，应由召回质量、性能和维护成本共同决定。"," 如果相关性需求不断增加，或搜索明显影响交易负载，再评估独立搜索服务，而不是仅凭“少一个组件”就确定选型。",[40,1328,1329,1342],{},[43,1330,1331],{},[46,1332,1333,1336,1339],{},[49,1334,1335],{},"维度",[49,1337,1338],{},"本文 PostgreSQL 方案",[49,1340,1341],{},"Elasticsearch",[59,1343,1344,1355,1366,1377],{},[46,1345,1346,1349,1352],{},[64,1347,1348],{},"数据维护",[64,1350,1351],{},"单表向量可在事务内维护；",[64,1353,1354],{},"需要把业务数据同步到独立搜索索引，并处理同步失败、重试和核对",[46,1356,1357,1360,1363],{},[64,1358,1359],{},"相关性",[64,1361,1362],{},"可配置字段权重，并按词项及位置排名；不等于自动获得合适的业务排序",[64,1364,1365],{},"默认采用 BM25，分析器、同义词及查询能力更丰富；中文分词方案仍需单独选择",[46,1367,1368,1371,1374],{},[64,1369,1370],{},"资源开销",[64,1372,1373],{},"少部署一套服务，但分词、GIN 维护和搜索会与业务负载争用数据库资源",[64,1375,1376],{},"增加独立搜索服务及其容量、分片、映射和运维成本",[46,1378,1379,1382,1385],{},[64,1380,1381],{},"适用判断",[64,1383,1384],{},"需要验证业务搜索需求能否由当前能力满足",[64,1386,1387],{},"需要验证新增检索能力是否值得相应的同步与运维投入",[10,1389,1390],{},"这次实验的价值，不在于提前宣布 PG 可以替代 ES，而在于把“能搜到”拆成可以逐项验证的问题：分词是否合适，查询语义是否一致，向量是否及时维护，以及性能是否满足业务要求。每一步有可复现的输入和输出，最终的选型结论才有依据。",[1392,1393],"hr",{},[10,1395,1396],{},"#PG  #PostgreSQL  #分词  #全文检索  #触发器",[1398,1399,1400],"style",{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":130,"searchDepth":144,"depth":144,"links":1402},[1403,1404,1409,1414,1415,1416],{"id":26,"depth":144,"text":27},{"id":367,"depth":144,"text":368,"children":1405},[1406,1407,1408],{"id":372,"depth":159,"text":373},{"id":570,"depth":159,"text":571},{"id":768,"depth":159,"text":769},{"id":901,"depth":144,"text":902,"children":1410},[1411,1412,1413],{"id":905,"depth":159,"text":906},{"id":936,"depth":159,"text":937},{"id":988,"depth":159,"text":989},{"id":1026,"depth":144,"text":1027},{"id":1134,"depth":144,"text":1135},{"id":1318,"depth":144,"text":1319},"article","2026-10-23","基于 PostgreSQL 18.6、zhparser 2.4 和 pg_trgm 1.6，实测中文商品全文检索的实现流程，涵盖扩展安装、中文分词、搜索向量与触发器维护、历史数据回填、GIN 索引、查询语义、相关性评分、近似匹配和自定义词典，并讨论何时需要评估 Elasticsearch。","md",{"publishedAt":1422,"author":1423,"account":1424,"tags":1425,"source":1428,"downloadedAt":1429},"2026-10-23T21:29:00+08:00","码上智能","一起莱吧",[1426,37,95,1427,1341],"PostgreSQL","全文检索","https:\u002F\u002Fmp.weixin.qq.com\u002Fs\u002Fa-GscWdldVi1_vOvI7rYcA","2026-10-24T22:51:37+08:00","\u002Farticles\u002Fes-postgresql",{"title":5,"description":1419},{"loc":1430,"images":1433},[1434,1435,1436,1437],{"loc":16},{"loc":915},{"loc":975},{"loc":1008},"articles\u002F先别急着上 ES：一次 PostgreSQL 中文商品全文搜索的实测记录","b_KVJYap1x8XMDJ0rQIOtwIAlIy129s4pXwedYJ6fME",1793263560328]