"""安全答案库:员工怎么问的。 **要修的是什么。** 答案库里的 71 条全部是按**客户安全问卷**的口吻写的 —— 「贵公司是否部署数据防泄漏(DLP)方案?」—— 而员工问的是「我能用个人电脑写公司 代码吗」。这两句话没有任何公共子串。 `security_faq_rag.py` 已经用语义检索把这条鸿沟补了一半(它的模块文档就是从这句 「这个搜索很蠢」开始写的)。但语义检索救的是**同一件事换一种说法**里语义确实靠近 的那部分;它救不了词表本身 —— 「个人电脑」和「BYOD」在向量上并不特别近,而条目里 只写后者。 `support_kb_articles.search_aliases` 解决的是同一个问题,而且解法已经被证明有效: 把员工真实的问法直接写进条目,词法路径直接 ILIKE 到,语义路径把它拼进嵌入文本。 这一列是同一件东西在另一套语料上的镜像 —— 刻意**不复用 `tags`**:tags 是安全团队 自己的分类关键词,把员工口语塞进去会同时污染分类筛选和它在嵌入文本里的语义。 写入这一列的唯一通道是知识循环的提案队列(`knowledge_proposals`,迁移 593), 来源是 `auditor_security_faq_gaps` 里那些真实问过、却没被答上的问句。也就是说: 这一列里的每一个词,都是某个员工真的打出来过的。 ⚠️ 加了这一列之后,**已有的向量全部过期**:`embedding_text()` 现在会把它拼进去, 而一条有员工问法却按旧文本嵌过的条目,拿新问法照样搜不到。所以这个迁移把接受提案 时的失效交给应用层(`security_faq_wiki` 落地时清 `embedding`),而不是在这里一次性 把 71 条全清 —— 全清会让下一次员工提问在重嵌完成之前退化成纯词法,而那正是这次 要修的那条路。没有员工问法的条目,它的嵌入文本一个字都没变。 Revision ID: 594 Revises: 593 ⚠️ 迁移号要对着 `origin/main` 抢,而且每次 push 之前重新抢一次。 """ from __future__ import annotations import sqlalchemy as sa from alembic import op revision = "594" down_revision = "593" branch_labels = None depends_on = None ENTRIES = "auditor_answer_library_entries" COLUMN = "employee_phrasings" def _has_column(bind, table: str, column: str) -> bool: return bind.execute( sa.text( "SELECT 1 FROM information_schema.columns " "WHERE table_schema = DATABASE() " "AND table_name = :t AND column_name = :c" ), {"t": table, "c": column}, ).scalar() is not None def _has_table(bind, name: str) -> bool: return bind.execute( sa.text( "SELECT 1 FROM information_schema.tables " "WHERE table_schema = DATABASE() AND table_name = :n" ), {"n": name}, ).scalar() is not None def upgrade() -> None: bind = op.get_bind() if not _has_table(bind, ENTRIES): return if not _has_column(bind, ENTRIES, COLUMN): # Plain text, newline-separated — the same shape as # ``support_kb_articles.search_aliases``, for the same reason: the # lexical path ILIKEs it directly and the semantic path concatenates it # into the embedding input, neither of which wants JSON. op.add_column(ENTRIES, sa.Column(COLUMN, sa.Text(), nullable=True)) def downgrade() -> None: bind = op.get_bind() if _has_table(bind, ENTRIES) and _has_column(bind, ENTRIES, COLUMN): op.drop_column(ENTRIES, COLUMN)