
过去一年,我们团队把内部的学习资料门户从关键词检索升级成了自然语言检索。员工不再需要思考我该输入什么关键词,而是直接说一句帮我找一下项目管理相关的课件,系统就能返回命中率足够高的结果。这个看起来理所当然的体验,背后并不是把一套现成的搜索框架搬过来——而是一次针对企业内部知识库规模、单业务域、短文本场景的工程化权衡。本文想把这个 feature 的亮点拆开讲清楚,并把我们踩过的坑和最终的实现路径也一并交代。一、从关键词到语义:为什么我们没有直接上向量数据库企业学习门户的内容构成很特殊:90% 是结构化短文本——课件标题、作者、一段不超过 500 字的描述,以及若干标签;真正长的视频和 PDF 文件,通常被作为附件挂载,用户在搜索框里几乎不会指名道姓地说找那 200 页的 PDF。这种数据结构对精确匹配()特别友好,但对自然语言问句几乎失效。最早我们用的是 PostgreSQL 自带的ILIKE %关键词%,对courseware.title / description / author三列做 OR 子串匹配。问题在第一周就显化了:“项目管理” 这类短语命中还行,但 “Java 入门” “BJC 业务介绍” “适合新员工的财务课程” 这类组合,几乎全军覆没;用户输入常常带有帮我找一下“有没有”相关的这种铺垫语,直接拿去 LIKE 连标题里都不存在的字都查;想命中标签维度的话还得做 JOIN,大表 JOIN 是一笔隐性成本。我们也考虑过 pgvector embedding:把每个课件标题和描述灌进去,搜索时把用户问句向量化做余弦相似度。但这个方案有两个硬伤,一是中文短文本(尤其是十几个字的标题)的向量召回噪声很大,项目管理和项目实践在向量空间里的距离远到让人失望;二是向量库和业务库一旦割裂,就引入了一份额外的数据同步、备份、扩容的成本,对于一个内部学习门户来说明显过度。最后我们转向了LLM-to-SQL这个当时还比较小众的方向:让大模型直接写 SELECT,把用户问句 → WHERE 子句这一步外包给 LLM,我们只负责给一条安全闸。这一步看起来激进,但落地的核心不是敢不敢用,而是安全闸能不能写得足够结实。二、用户能感知到的 feature 亮点落到产品层面,v1.1 这一版给前台带来的体验改进,可以浓缩成五个关键词。第一,真正支持一句话。UI 上首页只有一个搜索框,placeholder 写得就是请帮找一下项目管理相关的课程。用户提交后,后端会先做一层中文查询清洗——把请“麻烦”“帮我找一下”相关的这些铺垫语气用正则剥掉,提取出核心名词,再喂给 LLM。这样即使你输入一句 30 字的完整问句,模型也只会看到 2–4 个有效关键词。第二,检索深度跨四张表。LLM 生成的 SQL 能在courseware.title / description / author上做 ILIKE,也能在tag.name上做 EXISTS 子查询;提示词里我们还把当前数据库里category.name和tag.name的实际值动态注进去,让模型知道项目实践是一个已有的分类名,而不只是一个普通短语。第三,降级体验无感。LLM 不可用、调用超时、模型输出的 SQL 没有通过安全闸——这三种情况任意一种发生,系统都会自动切回纯 ILIKE 兜底。响应里会带一个source字段:llm表示这是模型召回的,keyword表示降级了;前端拿到后只做一个轻量的徽标变化,业务侧的用户体验几乎察觉不到。第四,响应时间可控。默认 8 秒 LLM 超时,通过asyncio.wait_for在路由层强约束。即便模型在那一秒宕了,前台也只是慢了 8 秒——不会把整个请求拖到分钟级。第五,管理员侧零运维成本。整个搜索链路没有引入任何额外组件:仍是那一台 PostgreSQL、仍是那一台 FastAPI,只是多了一个外部 HTTP 调用给通义千问。备份、扩缩容、监控的复杂度保持不变。三、实现路径:三段式服务 7 道 SQL 安全闸整个检索链路在代码里分成三个模块,各自职责清晰:backend/app/services/llm_sql.py——只负责调通义千问直写 SELECT,把数据库 schema 拼成一段 system prompt,把用户的原始问句作为 user prompt 喂进去。模型返回后做最基础的 markdown 去码块处理,然后把字符串交给下一道闸。backend/app/services/_sql_safety.py——纯函数,无数据库依赖,负责7 道闸白名单校验。这是整个方案的工程精髓,我们会在下一节展开。backend/app/services/search.py——编排器,先尝试 LLM 路径,任一环节失败就立即降级到关键词路径。最终吐出一个SearchResult对象:{items, source, total},source是字面意义上的 “llm” 或 “keyword”。数据库侧,所有可能的 LLM 写入尝试都被一道SET TRANSACTION READ ONLY堵死——这条语句必须在事务首条语句发出,所以即便模型犯了错,PostgreSQL 也会在最外层抛异常,我们 catch 住后当成无结果处理,绝不落到磁盘。配置面有四个环境变量值得记一下:DASHSCOPE_API_KEY必填(留空则整条 LLM 路径都跳过);LLM_SEARCH_MODEL默认qwen-plus;LLM_SQL_TIMEOUT_SECONDS默认8;LLM_SQL_MAX_ROWS默认20。这套默认值跑了一年,期间没有调过。四、最值得看的两道闸:清洗与白名单把上面那段直接展开看,有两道闸最值钱的。第一,中文查询的预处理。用户的原始问句里帮我找一下“有没有”相关的这类铺垫语,如果不洗,模型会带着这些废话进 prompt,生成的 SQL 也常常 hit miss。在 search.py 里我们用一组正则把这些停用片段剥掉,然后切词:_CN_QUERY_TRIM_PATTERNS:List[Tuple[re.Pattern[str],str]][(re.compile(r^(请|麻烦|劳驾)?(帮|为|替|给)?(我|我们)?(找一?找|寻|搜一?下|搜|查一?下|查|看一下|看下|了解|学习|查询|检索|找一?下|扒|怼|找一?下)),),(re.compile(r(与|跟|和|关于)([^,。!\s]?)(相关|有关|相关 的|有关 的)),r\2),]这段正则的目的不是把句子标准化,而是把 30 字的问句瘦身成 2–4 个关键词,再交给 LLM,效果立竿见影。第二,SQL 的 7 道闸白名单。这是整个方案的灵魂,直接定义在_sql_safety.validate_sql里,依次为:去 markdown 代码块包裹;单语句(末尾去;,中间不允许;);无注释(--行注释、/* */块注释一律拒绝);首词必须为SELECT;黑名单关键字整词匹配(DROP / DELETE / UPDATE / INSERT / ALTER / CREATE / TRUNCATE / GRANT / REVOKE / EXEC / COPY / VACUUM / LOCK / COMMENT / SET / RESET / CALL / DO / UNION / INTERSECT / EXCEPT);FROM / JOIN 中的表必须在白名单内,白名单 {courseware, category, tag, material, courseware_tag, courseware_material, courseware_access_token},pg_*/information_schema直接拒绝;强制 LIMIT(缺失则补LIMIT 20,显式LIMIT 50截断到 50)。任一道闸失败就返回None,调用方顺滑地降级到关键词路径。这里的设计哲学是:LLM 再聪明,我们也不让它写出能越权或把数据库拖垮的 SQL。即便模型攻击自己,我们也只能拿到一个被白名单限定的、只读最多 50 行的 SELECT。另一个工程化的小心思是vocab 缓存。每次让 LLM 写 SQL 之前,我们会查一次数据库,把当前所有的category.name和tag.name实际值拼到 prompt 末尾,告诉模型如果用户问的词和这些分类/标签名近似,要主动考虑JOIN category / JOIN tag。这一步查询每次不到 1 毫秒,但 prompt 会随业务增长而变大,所以加了进程级缓存,TTL 60 秒——后台增删分类 60 秒内生效,这个延迟可以接受。五、演进路径与一些没踩的坑把LLM 直写 SQL做成生产可用的工程产物,关键不在于模型,而在于工程纪律:白名单 降级 超时 只读事务这四件事缺一不可。我们曾经短暂考虑过把 SQL 生成结果在数据库里 EXPLAIN 一遍做语义校验,后来发现开销远大于收益;最终只保留了应用层静态白名单 PostgreSQL 事务层 only-read 双重防线。和 Elasticsearch / pgvector 方案比,LLM-to-SQL 在中文短文本场景下有两个独有优势:零额外组件(不需要向量库、不需要同步管道)、召回率高(模型懂中文短语);缺点也明显——模型调用成本随请求线性增长,且没办法做语义级别的近义词扩展。所以我们把它定位为 v1.1 到 v2.0 之间的过渡方案:v2.0 计划引入独立的搜索服务做同义词扩展和长文本召回,LLM-to-SQL 保留作为长句入口——两条路径并存。这套方案真正给团队带来的最大变化,是搜索终于从功能变成了产品力。以前我们做演示时最怕别人问如果用户换个说法你还找得到吗,现在我们可以很坦然地说:换 10 个说法,它都能找到。这件事的杠杆比看起来大得多——当员工开始信任搜索结果,他们才会真正去用,知识库才会从堆资料的仓库变成找得到答案的地方。效果对照(运营一年后抽样):长尾问句命中率从约 38% 提升到 71%;前台平均响应时间 LLM 路径 1.4s / keyword 路径 0.2s;LLM 直降率(模型输出未通过 7 道闸)约 0.8%。